diff --git a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md index 5fa051d..85fd57d 100644 --- a/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md +++ b/docs/decomposition/modules/E1_M3_InteractionAndTargetingLayer.md @@ -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). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index cc40c81..21d2a69 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 9d23a0d..ec8f712 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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 diff --git a/docs/plans/NEO-23-implementation-plan.md b/docs/plans/NEO-23-implementation-plan.md new file mode 100644 index 0000000..c791182 --- /dev/null +++ b/docs/plans/NEO-23-implementation-plan.md @@ -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 module’s 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 interact’s `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. diff --git a/docs/reviews/2026-04-17-NEO-23.md b/docs/reviews/2026-04-17-NEO-23.md new file mode 100644 index 0000000..d22baf3 --- /dev/null +++ b/docs/reviews/2026-04-17-NEO-23.md @@ -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 reach–based 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** (post–follow-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** (post–follow-up) — E1.M3 **In Progress**; note text covers NEO-23 partial slice vs remaining contracts. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** (post–follow-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 repo’s 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 plan’s 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 plan’s 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). 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..4868ff0 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs @@ -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)); + } +} 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..871b15e --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs @@ -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; + +/// 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 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(); + 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`.