diff --git a/docs/plans/NEO-23-implementation-plan.md b/docs/plans/NEO-23-implementation-plan.md index 2ebf334..c791182 100644 --- a/docs/plans/NEO-23-implementation-plan.md +++ b/docs/plans/NEO-23-implementation-plan.md @@ -31,10 +31,10 @@ ## Acceptance criteria checklist -- [ ] **Server source of truth:** Client cannot persist a lock the server rejects; every **`POST`** response includes the **authoritative** `targetState` after evaluation (success or denial). -- [ ] **Denials:** **`selectionApplied`: `false`** ⇒ **required non-empty `reasonCode`**; **HTTP 200** for logical denials; **400** / **404** rules match NEO-9 spirit (unknown player → **404** before body; malformed v1 → **400**). -- [ ] **Movement coupling:** After **`POST /move`** or **`POST /move-stream`**, the same **`GET`**/ **`POST`** response reflects updated **`validity`** (e.g. walk out of stub radius ⇒ `out_of_range` on read without requiring a new select). -- [ ] **Contract names** in README + XML match the **illustrative** E1.M3 table ([Key contracts](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#key-contracts)); any renames are visible in this plan and in PR text, not only in code. +- [x] **Server source of truth:** Client cannot persist a lock the server rejects; every **`POST`** response includes the **authoritative** `targetState` after evaluation (success or denial). +- [x] **Denials:** **`selectionApplied`: `false`** ⇒ **required non-empty `reasonCode`**; **HTTP 200** for logical denials; **400** / **404** rules match NEO-9 spirit (unknown player → **404** before body; malformed v1 → **400**). +- [x] **Movement coupling:** After **`POST /move`** or **`POST /move-stream`**, the same **`GET`**/ **`POST`** response reflects updated **`validity`** (e.g. walk out of stub radius ⇒ `out_of_range` on read without requiring a new select). +- [x] **Contract names** in README + XML match the **illustrative** E1.M3 table ([Key contracts](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md#key-contracts)); any renames are visible in this plan and in PR text, not only in code. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs new file mode 100644 index 0000000..87c9881 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Targeting; + +public class PlayerTargetStateReaderTests +{ + [Fact] + public void ComputeValidity_ShouldReturnInvalidTarget_WhenLockIdNotInRegistry() + { + var snap = new PositionSnapshot(0, 0, 0, 0); + Assert.Equal(TargetValidity.InvalidTarget, PlayerTargetStateReader.ComputeValidity("not_in_registry", in snap)); + } + + [Fact] + public void ComputeValidity_ShouldReturnNone_WhenLockNull() + { + var snap = new PositionSnapshot(0, 0, 0, 0); + Assert.Equal(TargetValidity.None, PlayerTargetStateReader.ComputeValidity(null, in snap)); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs new file mode 100644 index 0000000..09b8753 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs @@ -0,0 +1,290 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Targeting; + +/// Integration tests for (NEO-23). +public class TargetingApiTests +{ + [Fact] + public async Task GetTarget_ShouldReturnNone_WhenNoLock() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/game/players/dev-local-1/target"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(PlayerTargetStateResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal("dev-local-1", body.PlayerId); + Assert.Null(body.LockedTargetId); + Assert.Equal(TargetValidity.None, body.Validity); + Assert.Equal(0, body.Sequence); + } + + [Fact] + public async Task PostSelect_ShouldApplyLock_WhenInRange() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.SelectionApplied); + Assert.Null(body.ReasonCode); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); + Assert.Equal(TargetValidity.Ok, body.TargetState.Validity); + Assert.Equal(1, body.TargetState.Sequence); + } + + [Fact] + public async Task PostSelect_ShouldDenyUnknownTarget_WithReasonCode() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = "not_a_real_target", + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.SelectionApplied); + Assert.Equal(TargetingApi.ReasonUnknownTarget, body.ReasonCode); + Assert.Null(body.TargetState.LockedTargetId); + Assert.Equal(TargetValidity.None, body.TargetState.Validity); + } + + [Fact] + public async Task PostSelect_ShouldDenyOutOfRange_WhenBetaFarFromSpawn() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.SelectionApplied); + Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode); + Assert.Null(body.TargetState.LockedTargetId); + } + + [Fact] + public async Task PostSelect_ShouldClearLock_WhenTargetIdOmitted() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + Assert.Equal( + HttpStatusCode.OK, + (await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + })).StatusCode); + + var clear = await client.PostAsync( + "/game/players/dev-local-1/target/select", + new StringContent( + $"{{\"schemaVersion\":{TargetSelectRequest.CurrentSchemaVersion}}}", + Encoding.UTF8, + "application/json")); + + Assert.Equal(HttpStatusCode.OK, clear.StatusCode); + var body = await clear.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.SelectionApplied); + Assert.Null(body.TargetState.LockedTargetId); + Assert.Equal(TargetValidity.None, body.TargetState.Validity); + } + + [Fact] + public async Task GetTarget_ShouldShowOutOfRange_AfterMoveWithoutSelect() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + Assert.Equal( + HttpStatusCode.OK, + (await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + })).StatusCode); + + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 50, Y = 0, Z = 50 }, + }; + Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); + + var get = await client.GetAsync("/game/players/dev-local-1/target"); + Assert.Equal(HttpStatusCode.OK, get.StatusCode); + var body = await get.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId); + Assert.Equal(TargetValidity.OutOfRange, body.Validity); + } + + [Fact] + public async Task PostSelect_ShouldReturnNotFound_WhenPlayerUnknown() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/game/players/missing-player/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostSelect_ShouldReturnBadRequest_WhenSchemaVersionWrong() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = 999, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostSelect_ShouldReturnBadRequest_WhenTargetIdWhitespaceOnly() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = " \t ", + }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostSelect_ShouldBeCaseInsensitive_ForTargetId() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = " PROTOTYPE_TARGET_ALPHA ", + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.SelectionApplied); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); + } + + [Fact] + public async Task PostSelect_ShouldStayIdempotent_WhenSameTargetReselect() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + var req = new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req); + var second = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + var b1 = await first.Content.ReadFromJsonAsync(); + var b2 = await second.Content.ReadFromJsonAsync(); + Assert.Equal(b1!.TargetState.Sequence, b2!.TargetState.Sequence); + } + + [Fact] + public async Task PostSelect_ShouldDenyOutOfRange_WhenReselectingSameLockWhileOutOfRange() + { + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + + var move = new MoveCommandRequest + { + SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, + Target = new PositionVector { X = 50, Y = 0, Z = 50 }, + }; + await client.PostAsJsonAsync("/game/players/dev-local-1/move", move); + + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.SelectionApplied); + Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode); + Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); + Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity); + } +} diff --git a/server/NeonSprawl.Server/Game/Targeting/IPlayerTargetLockStore.cs b/server/NeonSprawl.Server/Game/Targeting/IPlayerTargetLockStore.cs new file mode 100644 index 0000000..67be447 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Targeting/IPlayerTargetLockStore.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Targeting; + +/// In-memory per-player combat target lock for prototype HTTP (NEO-23). +public interface IPlayerTargetLockStore +{ + /// Current lock id (lowercase registry key) and sequence; missing player ⇒ (null, 0). + (string? LockIdLowercase, int Sequence) GetLockState(string playerId); + + /// Clears lock; bumps sequence only if a lock was present. + (string? LockIdLowercase, int Sequence) ApplyClear(string playerId); + + /// Sets lock to ; no sequence bump if already equal (idempotent). + (string? LockIdLowercase, int Sequence) ApplySet(string playerId, string targetIdLowercase); +} diff --git a/server/NeonSprawl.Server/Game/Targeting/InMemoryPlayerTargetLockStore.cs b/server/NeonSprawl.Server/Game/Targeting/InMemoryPlayerTargetLockStore.cs new file mode 100644 index 0000000..1926eae --- /dev/null +++ b/server/NeonSprawl.Server/Game/Targeting/InMemoryPlayerTargetLockStore.cs @@ -0,0 +1,73 @@ +namespace NeonSprawl.Server.Game.Targeting; + +/// Thread-safe prototype lock store; player keys are trimmed with ordinal case-insensitive lookup (same spirit as position APIs). +public sealed class InMemoryPlayerTargetLockStore : IPlayerTargetLockStore +{ + private readonly object mutex = new(); + private readonly Dictionary rows = new(StringComparer.OrdinalIgnoreCase); + + private readonly record struct PlayerLockRow(string? LockIdLowercase, int Sequence); + + public (string? LockIdLowercase, int Sequence) GetLockState(string playerId) + { + var key = playerId.Trim(); + if (key.Length == 0) + { + return (null, 0); + } + + lock (mutex) + { + return rows.TryGetValue(key, out var row) ? (row.LockIdLowercase, row.Sequence) : (null, 0); + } + } + + public (string? LockIdLowercase, int Sequence) ApplyClear(string playerId) + { + var key = playerId.Trim(); + if (key.Length == 0) + { + return (null, 0); + } + + lock (mutex) + { + if (!rows.TryGetValue(key, out var prev)) + { + return (null, 0); + } + + if (prev.LockIdLowercase is null) + { + return (null, prev.Sequence); + } + + var next = new PlayerLockRow(null, prev.Sequence + 1); + rows[key] = next; + return (null, next.Sequence); + } + } + + public (string? LockIdLowercase, int Sequence) ApplySet(string playerId, string targetIdLowercase) + { + var key = playerId.Trim(); + if (key.Length == 0) + { + return (null, 0); + } + + lock (mutex) + { + var prev = rows.TryGetValue(key, out var r) ? r : new PlayerLockRow(null, 0); + + if (string.Equals(prev.LockIdLowercase, targetIdLowercase, StringComparison.Ordinal)) + { + return (prev.LockIdLowercase, prev.Sequence); + } + + var next = new PlayerLockRow(targetIdLowercase, prev.Sequence + 1); + rows[key] = next; + return (next.LockIdLowercase, next.Sequence); + } + } +} diff --git a/server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs b/server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs new file mode 100644 index 0000000..1d3a800 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs @@ -0,0 +1,38 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.World; + +namespace NeonSprawl.Server.Game.Targeting; + +/// Computes from authoritative position + persisted lock (NEO-23). +public static class PlayerTargetStateReader +{ + public static string ComputeValidity(string? lockIdLowercase, in PositionSnapshot snap) + { + if (string.IsNullOrEmpty(lockIdLowercase)) + { + return TargetValidity.None; + } + + if (!PrototypeTargetRegistry.TryGet(lockIdLowercase, out var entry)) + { + return TargetValidity.InvalidTarget; + } + + if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius)) + { + return TargetValidity.OutOfRange; + } + + return TargetValidity.Ok; + } + + public static PlayerTargetStateResponse Build(string routePlayerId, string? lockIdLowercase, int sequence, in PositionSnapshot snap) => + new() + { + SchemaVersion = PlayerTargetStateResponse.CurrentSchemaVersion, + PlayerId = routePlayerId, + LockedTargetId = lockIdLowercase, + Validity = ComputeValidity(lockIdLowercase, in snap), + Sequence = sequence, + }; +} diff --git a/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs b/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs new file mode 100644 index 0000000..f339bdb --- /dev/null +++ b/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs @@ -0,0 +1,28 @@ +namespace NeonSprawl.Server.Game.Targeting; + +/// Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase. +public static class PrototypeTargetRegistry +{ + /// Near default dev spawn (-5, -5) on XZ. + public const string PrototypeTargetAlphaId = "prototype_target_alpha"; + + /// Farther stub for out-of-range tests; ascending id order for NEO-24 tab cycle. + public const string PrototypeTargetBetaId = "prototype_target_beta"; + + /// Alpha anchor aligned with spawn horizontal cell; on XZ only. + public static readonly PrototypeTargetEntry PrototypeTargetAlpha = new(-5, 0.5, -5, 8.0); + + public static readonly PrototypeTargetEntry PrototypeTargetBeta = new(8, 0, 8, 4.0); + + private static readonly Dictionary ById = new(StringComparer.Ordinal) + { + [PrototypeTargetAlphaId] = PrototypeTargetAlpha, + [PrototypeTargetBetaId] = PrototypeTargetBeta, + }; + + public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) => + ById.TryGetValue(targetIdLowercase, out entry); +} + +/// Horizontal reach on X/Z; inclusive boundary (same as NEO-9). +public readonly record struct PrototypeTargetEntry(double X, double Y, double Z, double LockRadius); diff --git a/server/NeonSprawl.Server/Game/Targeting/TargetStateDtos.cs b/server/NeonSprawl.Server/Game/Targeting/TargetStateDtos.cs new file mode 100644 index 0000000..99b865c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Targeting/TargetStateDtos.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Targeting; + +/// JSON body for GET /game/players/{{id}}/target and nested targetState on select (NEO-23). +public sealed class PlayerTargetStateResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// Lowercase registry id when locked; JSON null when no lock (key always serialized). + [JsonPropertyName("lockedTargetId")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string? LockedTargetId { get; init; } + + /// One of string constants. + [JsonPropertyName("validity")] + public required string Validity { get; init; } + + [JsonPropertyName("sequence")] + public int Sequence { get; init; } +} + +/// POST body for POST /game/players/{{id}}/target/select (NEO-23). +/// +/// Omit targetId or send JSON null to clear the lock. Whitespace-only after trim → HTTP 400. +/// +public sealed class TargetSelectRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// Registry key after trim + case-insensitive lookup; null or omitted ⇒ clear intent. + [JsonPropertyName("targetId")] + public string? TargetId { get; init; } +} + +/// POST response for target selection (NEO-23). +public sealed class TargetSelectResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("selectionApplied")] + public bool SelectionApplied { get; init; } + + [JsonPropertyName("targetState")] + public required PlayerTargetStateResponse TargetState { get; init; } + + /// Required when is false; omitted on success. + [JsonPropertyName("reasonCode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasonCode { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Targeting/TargetValidity.cs b/server/NeonSprawl.Server/Game/Targeting/TargetValidity.cs new file mode 100644 index 0000000..6518886 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Targeting/TargetValidity.cs @@ -0,0 +1,10 @@ +namespace NeonSprawl.Server.Game.Targeting; + +/// Serialized validity strings for (NEO-23). +public static class TargetValidity +{ + public const string None = "none"; + public const string Ok = "ok"; + public const string OutOfRange = "out_of_range"; + public const string InvalidTarget = "invalid_target"; +} diff --git a/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs b/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs new file mode 100644 index 0000000..efca25b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs @@ -0,0 +1,105 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.World; + +namespace NeonSprawl.Server.Game.Targeting; + +/// Maps GET/POST …/target selection APIs (NEO-23). +public static class TargetingApi +{ + public const string ReasonUnknownTarget = "unknown_target"; + public const string ReasonOutOfRange = "out_of_range"; + + public static WebApplication MapTargetingApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/target", + (string id, IPositionStateStore positions, IPlayerTargetLockStore locks) => + { + if (!positions.TryGetPosition(id, out var snap)) + { + return Results.NotFound(); + } + + var (lockId, seq) = locks.GetLockState(id); + var body = PlayerTargetStateReader.Build(id, lockId, seq, in snap); + return Results.Json(body); + }); + + app.MapPost( + "/game/players/{id}/target/select", + (string id, TargetSelectRequest? body, IPositionStateStore positions, IPlayerTargetLockStore locks) => + { + if (body is null || body.SchemaVersion != TargetSelectRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + if (!positions.TryGetPosition(id, out var snap)) + { + return Results.NotFound(); + } + + // Clear intent: property omitted or JSON null. + if (body.TargetId is null) + { + var cleared = locks.ApplyClear(id); + var stateAfter = PlayerTargetStateReader.Build(id, cleared.LockIdLowercase, cleared.Sequence, in snap); + return Results.Json( + new TargetSelectResponse + { + SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, + SelectionApplied = true, + TargetState = stateAfter, + }); + } + + var raw = body.TargetId; + var trimmed = raw.Trim(); + if (trimmed.Length == 0) + { + return Results.BadRequest(); + } + + var lookupKey = trimmed.ToLowerInvariant(); + if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) + { + var (lockUnknown, seqUnknown) = locks.GetLockState(id); + var stateUnknown = PlayerTargetStateReader.Build(id, lockUnknown, seqUnknown, in snap); + return Results.Json( + new TargetSelectResponse + { + SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, + SelectionApplied = false, + TargetState = stateUnknown, + ReasonCode = ReasonUnknownTarget, + }); + } + + if (!HorizontalReach.IsWithinHorizontalRadius(snap.X, snap.Z, entry.X, entry.Z, entry.LockRadius)) + { + var (lockFar, seqFar) = locks.GetLockState(id); + var stateFar = PlayerTargetStateReader.Build(id, lockFar, seqFar, in snap); + return Results.Json( + new TargetSelectResponse + { + SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, + SelectionApplied = false, + TargetState = stateFar, + ReasonCode = ReasonOutOfRange, + }); + } + + var applied = locks.ApplySet(id, lookupKey); + var stateOk = PlayerTargetStateReader.Build(id, applied.LockIdLowercase, applied.Sequence, in snap); + return Results.Json( + new TargetSelectResponse + { + SchemaVersion = TargetSelectResponse.CurrentSchemaVersion, + SelectionApplied = true, + TargetState = stateOk, + }); + }); + + return app; + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 5caf2d0..ee66d44 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,8 +1,10 @@ using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Targeting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddPositionStateStore(builder.Configuration); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -18,5 +20,6 @@ app.MapGet("/health", () => Results.Json(new app.MapPositionStateApi(); app.MapInteractionApi(); +app.MapTargetingApi(); app.Run(); diff --git a/server/README.md b/server/README.md index 4447ffb..48a1eb4 100644 --- a/server/README.md +++ b/server/README.md @@ -125,6 +125,55 @@ When **`allowed` is `true`**, **`reasonCode` is omitted** (clients must not requ Contract details and PR blurb: [NEO-9 implementation plan](../../docs/plans/NEO-9-implementation-plan.md). +## Targeting (NEO-23) + +Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/target`** returns **`PlayerTargetStateResponse`** v1; **`POST /game/players/{id}/target/select`** accepts **`TargetSelectRequest`** v1 and returns **`TargetSelectResponse`** v1. Player id rules match position/interact (trim + ordinal case-insensitive lookup in the in-memory store). **Horizontal lock range** uses **`HorizontalReach`** on **X/Z only** (inclusive radius), same floor-plane policy as NEO-9. + +**Soft lock:** the server keeps the persisted **`lockedTargetId`** until clear or a successful swap. If the player moves out of stub range, **`validity`** becomes **`out_of_range`** on **`GET`** and on **`POST`** echoes until they move back in range, clear, or select another valid target (see [NEO-23 implementation plan](../../docs/plans/NEO-23-implementation-plan.md)). + +Stub registry: `PrototypeTargetRegistry` in `NeonSprawl.Server/Game/Targeting/` — ids **`prototype_target_alpha`**, **`prototype_target_beta`** (ascending id order for future tab cycle). + +**`GET /game/players/{id}/target`** + +| Field | Meaning | +|--------|--------| +| `schemaVersion` | `1` | +| `playerId` | Echo of route `{id}` | +| `lockedTargetId` | Lowercase stub id when locked; JSON **`null`** when no lock (**key always present**) | +| `validity` | `none` (no lock), `ok`, `out_of_range`, or `invalid_target` (unknown id in store — should not occur in normal use) | +| `sequence` | Increments when the persisted lock **id** changes (clear or swap); unchanged on idempotent re-select of the same id | + +**`POST /game/players/{id}/target/select`** — body `schemaVersion` (`1`), optional **`targetId`**. Omit **`targetId`** or send JSON **`null`** to **clear** the lock. Non-null **`targetId`** is trimmed; **whitespace-only** after trim → **400**. + +Examples: + +```bash +curl -s http://localhost:5253/game/players/dev-local-1/target +``` + +```bash +curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}' +``` + +**HTTP** + +| Status | Meaning | +|--------|---------| +| **200** | **GET:** `PlayerTargetStateResponse` v1. **POST:** `TargetSelectResponse` v1 with **`targetState`** always set; **`selectionApplied`** `true` or `false`; when `false`, required **`reasonCode`**. | +| **400** | Invalid v1 body (wrong `schemaVersion`, malformed JSON, or **`targetId`** whitespace-only when provided). | +| **404** | Unknown **player** only (same as interact). | + +**`reasonCode` (POST v1, when `selectionApplied` is `false`)** + +| Code | When | +|------|------| +| `unknown_target` | Id not in the prototype target registry (after trim + case-insensitive lookup). | +| `out_of_range` | Horizontal distance on X/Z is **greater than** the target’s `lockRadius` (on the radius is **allowed** — inclusive `<=`). | + +When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success). + ## Solution From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.