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`.