NEO-23: add targeting HTTP API (TargetState v1, select intent)

pull/44/head
VinPropane 2026-04-17 21:06:59 -04:00
parent 8c9db789d0
commit 465c4b14eb
12 changed files with 699 additions and 4 deletions

View File

@ -31,10 +31,10 @@
## Acceptance criteria checklist ## 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). - [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).
- [ ] **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] **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). - [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).
- [ ] **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] **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 ## Technical approach

View File

@ -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));
}
}

View File

@ -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;
/// <summary>Integration tests for <see cref="TargetingApi"/> (NEO-23).</summary>
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<PlayerTargetStateResponse>();
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<TargetSelectResponse>();
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<TargetSelectResponse>();
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<TargetSelectResponse>();
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<TargetSelectResponse>();
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<PlayerTargetStateResponse>();
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<TargetSelectResponse>();
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<TargetSelectResponse>();
var b2 = await second.Content.ReadFromJsonAsync<TargetSelectResponse>();
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<TargetSelectResponse>();
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);
}
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>In-memory per-player combat target lock for prototype HTTP (NEO-23).</summary>
public interface IPlayerTargetLockStore
{
/// <summary>Current lock id (lowercase registry key) and sequence; missing player ⇒ <c>(null, 0)</c>.</summary>
(string? LockIdLowercase, int Sequence) GetLockState(string playerId);
/// <summary>Clears lock; bumps sequence only if a lock was present.</summary>
(string? LockIdLowercase, int Sequence) ApplyClear(string playerId);
/// <summary>Sets lock to <paramref name="targetIdLowercase"/>; no sequence bump if already equal (idempotent).</summary>
(string? LockIdLowercase, int Sequence) ApplySet(string playerId, string targetIdLowercase);
}

View File

@ -0,0 +1,73 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Thread-safe prototype lock store; player keys are trimmed with ordinal case-insensitive lookup (same spirit as position APIs).</summary>
public sealed class InMemoryPlayerTargetLockStore : IPlayerTargetLockStore
{
private readonly object mutex = new();
private readonly Dictionary<string, PlayerLockRow> 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);
}
}
}

View File

@ -0,0 +1,38 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Computes <see cref="PlayerTargetStateResponse.Validity"/> from authoritative position + persisted lock (NEO-23).</summary>
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,
};
}

View File

@ -0,0 +1,28 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase.</summary>
public static class PrototypeTargetRegistry
{
/// <summary>Near default dev spawn <c>(-5, -5)</c> on XZ.</summary>
public const string PrototypeTargetAlphaId = "prototype_target_alpha";
/// <summary>Farther stub for out-of-range tests; ascending id order for NEO-24 tab cycle.</summary>
public const string PrototypeTargetBetaId = "prototype_target_beta";
/// <summary>Alpha anchor aligned with spawn horizontal cell; <see cref="PrototypeTargetEntry.LockRadius"/> on XZ only.</summary>
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<string, PrototypeTargetEntry> ById = new(StringComparer.Ordinal)
{
[PrototypeTargetAlphaId] = PrototypeTargetAlpha,
[PrototypeTargetBetaId] = PrototypeTargetBeta,
};
public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) =>
ById.TryGetValue(targetIdLowercase, out entry);
}
/// <param name="LockRadius">Horizontal reach on X/Z; inclusive boundary (same as NEO-9).</param>
public readonly record struct PrototypeTargetEntry(double X, double Y, double Z, double LockRadius);

View File

@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>JSON body for <c>GET /game/players/{{id}}/target</c> and nested <c>targetState</c> on select (NEO-23).</summary>
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; }
/// <summary>Lowercase registry id when locked; JSON <c>null</c> when no lock (key always serialized).</summary>
[JsonPropertyName("lockedTargetId")]
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? LockedTargetId { get; init; }
/// <summary>One of <see cref="TargetValidity"/> string constants.</summary>
[JsonPropertyName("validity")]
public required string Validity { get; init; }
[JsonPropertyName("sequence")]
public int Sequence { get; init; }
}
/// <summary>POST body for <c>POST /game/players/{{id}}/target/select</c> (NEO-23).</summary>
/// <remarks>
/// Omit <c>targetId</c> or send JSON <c>null</c> to clear the lock. Whitespace-only after trim → HTTP 400.
/// </remarks>
public sealed class TargetSelectRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
/// <summary>Registry key after trim + case-insensitive lookup; <c>null</c> or omitted ⇒ clear intent.</summary>
[JsonPropertyName("targetId")]
public string? TargetId { get; init; }
}
/// <summary>POST response for target selection (NEO-23).</summary>
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; }
/// <summary>Required when <see cref="SelectionApplied"/> is <c>false</c>; omitted on success.</summary>
[JsonPropertyName("reasonCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasonCode { get; init; }
}

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Serialized <c>validity</c> strings for <see cref="PlayerTargetStateResponse"/> (NEO-23).</summary>
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";
}

View File

@ -0,0 +1,105 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Maps <c>GET/POST …/target</c> selection APIs (NEO-23).</summary>
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;
}
}

View File

@ -1,8 +1,10 @@
using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPositionStateStore(builder.Configuration); builder.Services.AddPositionStateStore(builder.Configuration);
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
var app = builder.Build(); var app = builder.Build();
@ -18,5 +20,6 @@ app.MapGet("/health", () => Results.Json(new
app.MapPositionStateApi(); app.MapPositionStateApi();
app.MapInteractionApi(); app.MapInteractionApi();
app.MapTargetingApi();
app.Run(); app.Run();

View File

@ -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). 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 targets `lockRadius` (on the radius is **allowed** — inclusive `<=`). |
When **`selectionApplied` is `true`**, **`reasonCode` is omitted** (clients must not require it on success).
## Solution ## Solution
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`. From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.