Merge pull request #44 from ViPro-Technologies/NEO-23-e1m3-targetstate-selection-api

NEO-23 e1m3 targetstate selection api
pull/45/head
VinPropane 2026-04-17 21:18:17 -04:00 committed by GitHub
commit 114bebc8da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 877 additions and 5 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E1.M3 |
| **Epic** | [Epic 1 — Core Player Runtime](../epics/epic_01_core_player_runtime.md) |
| **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
| **Status** | In Progress (see [dependency register](module_dependency_register.md)) |
| **Linear** | [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23) through [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27) ([backlog](../../plans/E1M3-prototype-backlog.md)); label **`E1.M3`** per [decomposition README — Linear alignment](../README.md#linear-alignment) |
## Purpose
@ -100,7 +100,7 @@ flowchart LR
## Implementation snapshot
- **E1.M3 contracts (`TargetState`, `InteractableDescriptor`, `SelectionEvent`):** not implemented as named, versioned surfaces yet; register status remains **Planned** until wire/proto or agreed JSON v1 lands with a minimal consumer. **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
- **`TargetState` (prototype JSON v1 — NEO-23):** server **`GET /game/players/{id}/target`** and **`POST /game/players/{id}/target/select`** with versioned **`PlayerTargetStateResponse`** / **`TargetSelectRequest`** / **`TargetSelectResponse`**; stub registry + in-memory lock under `server/NeonSprawl.Server/Game/Targeting/`; horizontal reach + soft lock semantics — [NEO-23](../../plans/NEO-23-implementation-plan.md), [server README — Targeting](../../../server/README.md#targeting-neo-23). **Still open on later issues:** **`InteractableDescriptor`**, **`SelectionEvent`**, Godot tab-target ([NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)). **Story backlog:** [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md).
- **Precursor under E1.M1 (NEO-9):** server **`POST /game/players/{id}/interact`** with **`InteractionRequest`** / **`InteractionResponse`**; **`HorizontalReach`** (`server/NeonSprawl.Server/Game/World/HorizontalReach.cs`); prototype registry and API under `server/NeonSprawl.Server/Game/Interaction/`; client **`interaction_request_client.gd`**, **`interaction_radius_indicators.gd`**, **`prototype_interaction_constants.gd`** — see [NEO-9](../../plans/NEO-9-implementation-plan.md) and [server README — Interaction](../../../server/README.md#interaction-neo-9).
- **Alignment:** [Documentation and implementation alignment](documentation_and_implementation_alignment.md).

View File

@ -48,7 +48,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|--------|-----------------|----------|-------------------|
| E1.M1 | Ready | Prototype milestone **Done** ([E1.M1](https://linear.app/neon-sprawl/project/e1m1-inputandmovementruntime-20eb28dd3db4)). Authoritative `PositionState` + **MoveCommand** over HTTP (JSON v1 snap); **in-memory** store by default, **Postgres** when configured ([NEO-8](../../plans/NEO-8-implementation-plan.md)); shared **NpgsqlDataSource** disposed on host shutdown ([NEO-13](../../plans/NEO-13-implementation-plan.md)); Godot sync + path-follow ([NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md)); **InteractionRequest** + horizontal range ([NEO-9](../../plans/NEO-9-implementation-plan.md)). Follow-on: prediction/reconciliation, Slice 1 telemetry, Protobuf wire per [contracts.md](contracts.md). | [NEO-6](../../plans/NEO-6-implementation-plan.md), [NEO-7](../../plans/NEO-7-implementation-plan.md), [NEO-8](../../plans/NEO-8-implementation-plan.md), [NEO-9](../../plans/NEO-9-implementation-plan.md), [NEO-10](../../plans/NEO-10-implementation-plan.md), [NEO-11](../../plans/NEO-11-implementation-plan.md), [NEO-13](../../plans/NEO-13-implementation-plan.md); `server/NeonSprawl.Server/Game/PositionState/`, `Game/Interaction/`; [server README](../../../server/README.md) |
| E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) |
| E1.M3 | Planned | **No E1.M3-named contracts in code yet** (`TargetState`, `InteractableDescriptor`, `SelectionEvent`). **Precursor** for interact range + `POST …/interact` is **E1.M1** ([NEO-9](../../plans/NEO-9-implementation-plan.md)); see [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md#boundary-e1m1-vs-e1m3). Register stays **Planned** until first E1.M3-tagged implementation merges. | Linear [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23) through [NEO-27](https://linear.app/neon-sprawl/issue/NEO-27) (Todo); register unchanged until code merges. [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [NEO-9](../../plans/NEO-9-implementation-plan.md); [server README — Interaction](../../../server/README.md#interaction-neo-9) |
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **`InteractableDescriptor`**, **`SelectionEvent`**, and client tab-target ([NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)) still open. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interaction](../../../server/README.md#interaction-neo-9) |
---

View File

@ -14,12 +14,12 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---|
| E1.M1 | InputAndMovementRuntime | None | MoveCommand, PositionState, InteractionRequest | Prototype | Ready |
| E1.M2 | IsometricCameraController | E1.M1 | CameraState, ZoomBandConfig, OcclusionPolicy | Prototype | Ready |
| E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | Planned |
| E1.M3 | InteractionAndTargetingLayer | E1.M1 | TargetState, InteractableDescriptor, SelectionEvent | Prototype | In Progress |
| E1.M4 | AbilityInputScaffold | E1.M3, E5.M1 | AbilityCastRequest, HotbarLoadout, CooldownSnapshot | Prototype | Planned |
**E1.M2 note:** Prototype slice **Ready**: `client/scripts/isometric_follow_camera.gd` + child `Camera3D` on **`World/IsometricFollowCamera`** in `client/scenes/main.tscn`; per-tick **`CameraState`** (`client/scripts/camera_state.gd`); **`ZoomBandConfig`** ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); **`OcclusionPolicy`** ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); pick-through **`"occluder"`** convention ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); consumer contract + occluder lifecycle hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). See [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md).
**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **`TargetState` / `InteractableDescriptor` / `SelectionEvent`** are still **Planned** until published wire (Protobuf or agreed JSON v1) plus a minimal consumer. **Precursor:** authoritative **`InteractionRequest`** + horizontal reach lives under **E1.M1** ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`).
**E1.M3 note:** Decomposition expanded in [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md) (E1.M1 vs E1.M3 boundary, contract sketches, Slice 3 alignment). **Partial (NEO-23):** versioned JSON v1 **`PlayerTargetStateResponse`** + **`POST …/target/select`** under `server/NeonSprawl.Server/Game/Targeting/` ([NEO-23](../../plans/NEO-23-implementation-plan.md); [server README — Targeting](../../../server/README.md#targeting-neo-23)) — throwaway HTTP spike per [contracts.md](contracts.md). **`InteractableDescriptor`** / **`SelectionEvent`** and a Godot consumer for targeting remain **in progress** on later Linear issues. **Precursor (E1.M1):** **`InteractionRequest`** + horizontal reach ([NEO-9](../../plans/NEO-9-implementation-plan.md); `Game/Interaction/`, `Game/World/HorizontalReach.cs`).
### Epic 2 — Skills and Progression Framework

View File

@ -0,0 +1,99 @@
# NEO-23 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-23 |
| **Title** | E1.M3: TargetState v1 + server selection intent |
| **Linear** | [NEO-23](https://linear.app/neon-sprawl/issue/NEO-23/e1m3-targetstate-v1-server-selection-intent) |
| **Git branch** | `NEO-23-e1m3-targetstate-selection-api` |
| **Parent context** | [Epic 1 — Core Player Runtime](https://linear.app/neon-sprawl/project/epic-1-core-player-runtime-client-controls-character-loop-66bd590cd016) · [E1.M3 prototype backlog](E1M3-prototype-backlog.md) — **E1M3-01** |
| **Decomposition** | [E1.M3 — InteractionAndTargetingLayer](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) (key contracts, boundary vs [NEO-9](NEO-9-implementation-plan.md)) |
## Goal, scope, and out-of-scope
**Goal:** Ship **versioned JSON** over the **existing HTTP** prototype channel: **authoritative `TargetState`** per player plus a **client→server selection intent** (working name **`TargetSelectRequest`**) such that the **server** is the only source of truth for who is locked, with **`reasonCode`** denials aligned with [NEO-9](NEO-9-implementation-plan.md) / `InteractionResponse` conventions (handled attempt → **200** + structured body; **400** / **404** only where the interaction route already does).
**In scope**
- **Stub eligible combat targets:** static registry (≥ **2** distinct ids with world anchors + horizontal **lock radius**) so [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) can assume deterministic cycling without expanding scope here beyond “exists and validates.”
- **Selection intent:** `POST` with v1 body; **clear lock** when `targetId` is **omitted or JSON `null`** (explicit rule in XML + README). **Trim** non-null ids; **whitespace-only** after trim → **400** (same spirit as empty `interactableId` on NEO-9).
- **Authoritative read model:** `TargetState` includes **`lockedTargetId`** (**always** serialized; JSON **`null`** when no lock — never omit the key), **`validity`** derived from **current** `PositionState` vs stub anchor + radius using **`HorizontalReach`** (X/Z only, inclusive boundary — same policy as NEO-9), and **`sequence`** bumped only when the **persisted lock id** changes (aligns with “optional sequence” in the modules illustrative table).
- **`GET`** current `TargetState` for the same player id normalization rules as position/interact APIs.
- **Unit + integration tests** mirroring `InteractionApiTests` patterns; **server README** subsection documenting routes, curl, and `reasonCode` table.
**Out of scope (per Linear / backlog)**
- Protobuf wire; full entity sim; replacing **`POST …/interact`** (remains separate).
- PvP / faction gating beyond a single optional **`invalid_target`** (or reserved code) if we need a stub deny path for API shape — **no** real [E6.M1](../decomposition/modules/E6_M1_PvPEligibilityAndFlagState.md) logic.
- Godot tab-target UI, HUD, optimistic highlight — **[NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)**.
## Acceptance criteria checklist
- [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
1. **New feature folder** `NeonSprawl.Server/Game/Targeting/` (keeps E1.M3 surface distinct from `Game/Interaction/` NEO-9 “use now” interactable path).
2. **Prototype stub registry** (e.g. `PrototypeTargetRegistry`): lowercase keys → anchor **X/Z** (Y stored but ignored for reach) + **`lockRadius`**. At least **two** rows at distinct horizontal positions so lock + range tests and NEO-24 ordering are unblocked. Constants duplicated in Godot **only when** NEO-24 adds preview; **not** required for NEO-23 if server-only.
3. **Mutable per-player lock** (e.g. `IPlayerTargetLockStore` + `InMemoryPlayerTargetLockStore`): stores **nullable locked target id** + **monotonic `sequence`** incremented **only** when the lock id changes (clear or swap). Thread-safe in-memory singleton for prototype (same lifetime pattern as `InMemoryPositionStateStore`).
4. **`TargetState` fields (v1 JSON, shared by GET body and nested POST echo):** `schemaVersion`, `playerId` (echo), **`lockedTargetId`** (string or JSON **`null`** — key **always present**), `validity` (`ok`, `out_of_range`, `invalid_target`, `none`), `sequence` (int). **Validity** is **computed** on each read: no lock ⇒ `validity: none` and `lockedTargetId: null`; unknown id in store (should not happen) ⇒ `invalid_target`; else horizontal distance vs **`lockRadius`**.
5. **`TargetSelectRequest` / `TargetSelectResponse` v1:** Request: `schemaVersion`, optional `targetId`. Response: `schemaVersion`, **`selectionApplied`** (bool), **`targetState`** (object matching the GET field set above — always present), **`reasonCode`** (required when `selectionApplied` is `false`, omitted when `true` — same spirit as `InteractionResponse`). Successful **clear**`selectionApplied: true`, `targetState` shows `lockedTargetId: null`, `validity: none`.
6. **Routes** (exact paths in README; suggested):
- `GET /game/players/{id}/target`**envelope** aligned with **`PositionStateResponse`**: top-level `schemaVersion`, `playerId`, plus lock fields (`lockedTargetId`, `validity`, `sequence`) so read and move APIs share the same “version + player echo + payload” pattern.
- `POST /game/players/{id}/target/select` → body `TargetSelectRequest`, response `TargetSelectResponse`.
7. **`Program.cs`:** register store + `app.MapTargetingApi()` (static extension parallel to `MapInteractionApi`).
8. **README:** new subsection **Targeting (NEO-23)** with curl, status table, `reasonCode` list (`unknown_target`, `out_of_range`, optional `invalid_target`, …).
**Decisions (locked — stakeholder 2026-04-18)**
| Topic | Choice |
|--------|--------|
| **`lockedTargetId` when no lock** | Property **always present**; value JSON **`null`** (never omit the key). |
| **GET body shape** | **Envelope** like `PositionStateResponse`: `schemaVersion`, `playerId`, then lock fields (easier evolution and parity with position GET). |
| **POST success flag** | **`selectionApplied`** (distinct from interacts `allowed` — avoids “permission” ambiguity). |
| **Out of range vs stored lock** | **Soft lock:** persisted lock **id** unchanged until clear/swap; **`validity`** becomes `out_of_range` when the player moves away until they clear or select again. **No auto-clear** on read or timer in NEO-23. |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs` | Maps GET/POST routes; normalizes player id; composes store + position + registry. |
| `server/NeonSprawl.Server/Game/Targeting/TargetStateDtos.cs` | JSON v1 DTOs: `TargetStateResponse` / `TargetStateBody` (split if needed), `TargetSelectRequest`, `TargetSelectResponse`. |
| `server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs` | Static stub targets (≥2 ids) with anchors + `lockRadius`. |
| `server/NeonSprawl.Server/Game/Targeting/IPlayerTargetLockStore.cs` | Abstraction for lock + sequence mutations. |
| `server/NeonSprawl.Server/Game/Targeting/InMemoryPlayerTargetLockStore.cs` | Prototype in-memory implementation. |
| `server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs` (optional single file) | Pure helper: given lock id + `PositionSnapshot` + registry → `validity` (keeps handler thin). |
| `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs` | Integration tests: select, deny unknown, deny out of range, clear, move then re-read validity, unknown player 404, bad schema 400. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register `IPlayerTargetLockStore` and call `MapTargetingApi()`. |
| `server/README.md` | Document NEO-23 endpoints, request/response fields, and `reasonCode` table next to Interaction (NEO-9). |
## Tests
| Test file | What to cover |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs` | **Apply lock** when in range; **deny** unknown id with `reasonCode` + `selectionApplied` false + unchanged authoritative state; **deny** out of range when in range for another stub only if requesting the far one (or walk seed position); **clear** lock via POST without `targetId`; **GET** after **POST /move** updates `validity` without a new select; **404** unknown player; **400** bad `schemaVersion` / whitespace `targetId`; **case-insensitive** target id lookup. |
| *(optional)* `server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs` | **Only if** validation is extracted: unit tests for validity transitions at inclusive radius boundary (same spirit as `HorizontalReachTests`). |
No automated Godot harness for NEO-23; manual verification is optional HTTP client / curl per README.
## Open questions / risks
- **NEO-24 UX:** document that UI may show **out of range** while **`lockedTargetId`** stays set until an explicit clear or a successful swap (soft lock).
- **Postgres vs in-memory target store:** prototype lock store is in-memory only for NEO-23; multi-instance deployment would need shared storage — out of scope until infra story exists.

View File

@ -0,0 +1,48 @@
# Code review — NEO-23 (targeting API)
**Date:** 2026-04-17
**Scope:** Branch `NEO-23-e1m3-targetstate-selection-api`; `git` range `main...HEAD` (working tree clean at review time). Linear **NEO-23**.
**Base:** `main` (local `origin/main` implied).
## Verdict
**Approve with nits**
## Summary
The branch adds the NEO-23 targeting surface under `server/NeonSprawl.Server/Game/Targeting/`: prototype registry, in-memory per-player lock + sequence, horizontal reachbased validity (soft lock), `GET /game/players/{id}/target` and `POST …/target/select`, DTOs aligned with the plan (`selectionApplied`, optional `reasonCode`, always-present `lockedTargetId`), integration tests in the same spirit as interaction APIs, and server README coverage. Behavior matches the written implementation plan for denials (200 + `reasonCode` when not applied), 400/404 boundaries, clear via omitted/null `targetId`, and movement-driven `validity` without auto-clear. `dotnet test NeonSprawl.sln` passes (55 tests).
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-23-implementation-plan.md` (branch) | **Matches** — routes, DTO names as documented (`PlayerTargetStateResponse`, `TargetSelectRequest`/`Response`), soft lock, sequence rules, README scope, and acceptance bullets align with the code under review. |
| `docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md` | **Matches** (postfollow-up) — Summary **Status**, **Implementation snapshot**, and NEO-23 `Game/Targeting/` + README pointers reflect the JSON v1 spike; later contracts remain explicitly open. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** (postfollow-up) — E1.M3 **In Progress**; note text covers NEO-23 partial slice vs remaining contracts. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** (postfollow-up) — E1.M3 row register + snapshot + plans cell aligned with NEO-23 merge and follow-on Linear issues. |
| `docs/decomposition/modules/contracts.md` (versioned JSON spike discipline) | **Matches** — explicit `schemaVersion`, throwaway JSON over HTTP, consistent with other prototype routes. |
**Register / tracking table:** ~~After merge, **should** update `module_dependency_register.md` and `documentation_and_implementation_alignment.md` (and optionally the E1.M3 module snapshot) so status and inventory match the new `Game/Targeting/` + README section.~~ **Done** on branch (same PR window as NEO-23).
## Blocking issues
(none)
## Suggestions
1. ~~**Doc alignment in merge train:** Update `module_dependency_register.md`, `documentation_and_implementation_alignment.md`, and the **Implementation snapshot** in `E1_M3_InteractionAndTargetingLayer.md` so E1.M3 is not described as having zero implementation once NEO-23 lands (per this repos own alignment policy). A follow-up doc-only PR is acceptable if called out explicitly for the merge.~~ **Done.**
2. ~~**Test gap — `GET` 404:** `PostSelect_ShouldReturnNotFound_WhenPlayerUnknown` covers unknown player on POST. Adding a parallel test for `GET /game/players/{id}/target` would lock the same contract as position/interact read paths.~~ **Done.**
3. ~~**`PlayerTargetStateReaderTests` depth:** The plans optional table called out inclusive-radius boundary cases (similar to `HorizontalReachTests`). Current unit tests cover `null` lock and unknown registry id only; adding one boundary assertion would better match the plans optional coverage intent.~~ **Done.**
## Nits
- **Nit:** Wire type name `PlayerTargetStateResponse` vs illustrative doc name `TargetState` is already explained in the NEO-23 plan and README; no change required if PR text points readers there once.
## Verification
- `dotnet test NeonSprawl.sln` (author ran: all passed).
- Manual: `curl` examples from `server/README.md` **Targeting (NEO-23)** against a running server (optional smoke).

View File

@ -0,0 +1,42 @@
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));
}
[Fact]
public void ComputeValidity_AtExactLockRadius_ReturnsOk()
{
// Alpha anchor XZ (-5, -5), lockRadius 8 → horizontal distance 8 from (3, -5) is on boundary (inclusive).
var snap = new PositionSnapshot(3, 0, -5, 0);
Assert.Equal(
TargetValidity.Ok,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
}
[Fact]
public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange()
{
const double eps = 1e-6;
var snap = new PositionSnapshot(3 + eps, 0, -5, 0);
Assert.Equal(
TargetValidity.OutOfRange,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
}
}

View File

@ -0,0 +1,300 @@
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 GetTarget_ShouldReturnNotFound_WhenPlayerUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/game/players/missing-player/target");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[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.PositionState;
using NeonSprawl.Server.Game.Targeting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPositionStateStore(builder.Configuration);
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
var app = builder.Build();
@ -18,5 +20,6 @@ app.MapGet("/health", () => Results.Json(new
app.MapPositionStateApi();
app.MapInteractionApi();
app.MapTargetingApi();
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).
## 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
From repo root: `dotnet build NeonSprawl.sln` and `dotnet test NeonSprawl.sln`.