From 902e03848ce73ff2afa9d3fa0c044ef06ee22dc4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:18:41 -0400 Subject: [PATCH 1/7] NEO-83: Add implementation plan for combat-targets GET snapshot. Kickoff clarifications recorded; server-only scope with Bruno and API test coverage for authoritative HP reads. --- docs/plans/NEO-83-implementation-plan.md | 137 +++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/plans/NEO-83-implementation-plan.md diff --git a/docs/plans/NEO-83-implementation-plan.md b/docs/plans/NEO-83-implementation-plan.md new file mode 100644 index 0000000..17cd6e3 --- /dev/null +++ b/docs/plans/NEO-83-implementation-plan.md @@ -0,0 +1,137 @@ +# NEO-83 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-83 | +| **Title** | E5M1-08: GET /game/world/combat-targets snapshot | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-83/e5m1-08-get-gameworldcombat-targets-snapshot | +| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-08** | +| **Branch** | `NEO-83-combat-targets-snapshot` | +| **Precursor** | [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) — `ICombatEntityHealthStore` (**Done** on `main`); [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast POST + `combatResolution` (**Done** on `main`) | +| **Pattern** | [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) — `GET /game/world/ability-definitions`; read-only world snapshot backed by injectable store | +| **Blocks** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client HUD (polls this route) | +| **Client counterpart** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) — Godot combat targets client + HP HUD (**out of scope** here; no client-facing change in this story) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Manual QA doc** | Add `docs/manual-qa/NEO-83.md`? | **Skip** — server-only; no Godot change; Bruno + API tests satisfy AC (NEO-78/NEO-82 pattern). | **User:** skip. | + +**No other blocking decisions** — route path (`GET /game/world/combat-targets`), envelope (`schemaVersion` **1**, **`targets`** array), row fields (`targetId`, `maxHp`, `currentHp`, `defeated`), lazy init for both registry dummies on read, ascending id order (alpha → beta), and store injection are specified in [E5M1-08](E5M1-prototype-backlog.md#e5m1-08--get-gameworldcombat-targets-snapshot) and [E5.M1 kickoff defaults](E5M1-prototype-backlog.md#kickoff-decisions-decomposition-defaults). + +## Goal, scope, and out-of-scope + +**Goal:** Expose a stable, read-only JSON snapshot of prototype combat dummy HP so clients and tooling can display authoritative target health without local damage math — backed by **`ICombatEntityHealthStore`** (same source of truth as cast resolution). + +**In scope (from Linear + [E5M1-08](E5M1-prototype-backlog.md#e5m1-08--get-gameworldcombat-targets-snapshot)):** + +- **`GET /game/world/combat-targets`** → versioned list (`schemaVersion` **1**, **`targets`** array). +- Each row: **`targetId`**, **`maxHp`**, **`currentHp`**, **`defeated`** — mapped from **`CombatEntityHealthSnapshot`**. +- Lazy-init **`prototype_target_alpha`** and **`prototype_target_beta`** on first read via **`TryGet`** (same policy as NEO-80). +- Targets ordered by **`targetId`** ascending (alpha before beta). +- API integration tests (AAA): fresh snapshot, post-cast damage reflection, defeated after lethal sequence. +- Bruno folder `bruno/neon-sprawl-server/combat-targets/` — happy GET + cast→GET defeat spine. +- **`server/README.md`** combat-targets section; cross-link NEO-80 health store + NEO-82 cast route. + +**Out of scope (from Linear + backlog):** + +- Godot wiring — **[NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)**. +- Per-target query filters, player HP, persistence, NPC spawn (E5.M2). +- **`TryResetToFull`** over HTTP (tests/fixtures only). +- **`docs/manual-qa/NEO-83.md`** (kickoff decision). + +## Acceptance criteria checklist + +- [ ] GET reflects damage applied via cast route without client-side HP math. +- [ ] Defeated flag true after lethal cast sequence in Bruno. + +## Technical approach + +1. **Route:** **`GET /game/world/combat-targets`** — parameterless read; inject **`ICombatEntityHealthStore`** only. + +2. **Target enumeration:** Add **`PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder()`** (or equivalent static ordered list) returning **`prototype_target_alpha`**, **`prototype_target_beta`** in ordinal id order — mirrors **`GetDefinitionsInIdOrder()`** pattern and avoids hardcoding ids in the API handler. + +3. **Handler flow (`CombatTargetsWorldApi`):** + - For each registry id in order, call **`healthStore.TryGet(id, out snapshot)`** (lazy-init on first access). + - Map each snapshot to **`CombatTargetJson`** (`targetId`, `maxHp`, `currentHp`, `defeated`). + - Return **`Results.Json(new CombatTargetsListResponse { SchemaVersion = 1, Targets = … })`**. + +4. **Wire DTOs (`CombatTargetsListDtos.cs`):** + - **`CombatTargetsListResponse`** — `schemaVersion` (const **1**), `targets` array. + - **`CombatTargetJson`** — camelCase JSON property names matching backlog + existing cast/combat field vocabulary. + +5. **DI / routing:** Wire **`app.MapCombatTargetsWorldApi()`** from **`Program.cs`** next to **`MapAbilityDefinitionsWorldApi()`** (after ability definitions world GET). + +6. **Integration tests (`CombatTargetsWorldApiTests.cs`):** + - **Fresh GET:** both targets at **`maxHp` 100**, **`currentHp` 100**, **`defeated` false**; ascending id order. + - **After one pulse cast on alpha:** GET shows alpha **`currentHp` 75**, beta still 100; alpha **`defeated` false**. + - **After four pulse casts on alpha:** GET shows alpha **`currentHp` 0**, **`defeated` true**; beta unchanged. + - Reuse cast setup helpers from **`AbilityCastApiTests`** pattern (bind slot 0 pulse, lock alpha, teleport in range). + +7. **Bruno (`bruno/neon-sprawl-server/combat-targets/`):** + - **`Get combat targets.bru`** — assert 200, **`schemaVersion` 1**, **`targets.length === 2`**, ascending ids, both at full HP on fresh server. + - **`Get combat targets after cast spine.bru`** — pre-request: move + hotbar + lock alpha + one pulse cast; GET asserts alpha **`currentHp` 75**; optional extended pre-request for four casts + **`defeated` true** on alpha (AC spine). + - **`folder.bru`** — run order note; cross-link ability-cast folder. + +8. **Docs:** Add **`server/README.md`** **`GET /game/world/combat-targets`** section with field table + curl example; update NEO-80 health store row (“HTTP read” no longer pending). Reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-08 checkboxes and [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md) when landed. + +### Expected snapshot values (prototype_pulse → alpha, fresh server) + +| Step | `prototype_target_alpha` | `prototype_target_beta` | +|------|--------------------------|-------------------------| +| Fresh GET (no casts) | 100 / 100, `defeated: false` | 100 / 100, `defeated: false` | +| After 1× pulse on alpha | 75 / 100, `defeated: false` | 100 / 100, `defeated: false` | +| After 4× pulse on alpha | 0 / 100, `defeated: true` | 100 / 100, `defeated: false` | + +(`currentHp` / `maxHp`) + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs` | Maps **`GET /game/world/combat-targets`**. | +| `server/NeonSprawl.Server/Game/Combat/CombatTargetsListDtos.cs` | Response envelope + row JSON types. | +| `server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs` | AAA integration tests: fresh, post-cast, defeat. | +| `bruno/neon-sprawl-server/combat-targets/Get combat targets.bru` | Bruno happy path. | +| `bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru` | Bruno cast → GET damage + defeat AC spine. | +| `bruno/neon-sprawl-server/combat-targets/folder.bru` | Folder docs + run order. | +| `docs/plans/NEO-83-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs` | Add **`GetPrototypeTargetIdsInOrder()`** for stable enumeration in GET handler + tests. | +| `server/NeonSprawl.Server/Program.cs` | Register **`MapCombatTargetsWorldApi()`** after ability definitions world GET. | +| `server/README.md` | Document combat-targets GET, field table, curl; update NEO-80 “HTTP read” row. | +| `docs/plans/E5M1-prototype-backlog.md` | Mark E5M1-08 landed when complete. | +| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Update status + related implementation slice for NEO-83. | +| `docs/decomposition/modules/module_dependency_register.md` | Extend E5.M1 note with NEO-83 landed summary. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row cites combat-targets GET. | + +## Tests + +| File | Coverage | +|------|----------| +| `server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs` | **Fresh GET:** schema v1, two targets, alpha/beta ids ascending, full HP, not defeated. **Post-cast:** bind pulse, lock alpha, one cast → GET alpha 75 HP. **Defeat chain:** four casts → GET alpha `currentHp` 0, `defeated` true; beta still full. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | + +Bruno spine covers manual HTTP verification; no **`docs/manual-qa/NEO-83.md`** (server-only, no Godot). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|---------------------|--------| +| **Manual QA doc** | **Skip** (user kickoff). | **adopted** | +| **Array property name (`targets` vs `combatTargets`)** | **`targets`** — matches `abilities` / `recipes` world GET naming. | **adopted** | +| **Always return both registry dummies** | **Yes** — lazy-init both on GET per E5M1-08 backlog. | **adopted** | +| **Shared factory with cast tests** | **Same `InMemoryWebApplicationFactory` instance** for cast→GET integration tests — verifies store singleton coherence. | **adopted** | +| **Bruno cooldown waits on defeat spine** | **Reuse NEO-82 pattern** (~3.2s between pulse casts in pre-request when asserting four-pulse defeat). | **adopted** | + +## Decisions (kickoff) + +- No manual QA doc; Bruno + README + API tests sufficient (server-only — NEO-85 owns Godot HUD). +- Wire envelope: **`schemaVersion` 1**, **`targets`** array with **`targetId`**, **`maxHp`**, **`currentHp`**, **`defeated`**. +- Always return both prototype registry targets in ascending id order; lazy-init via **`TryGet`** on read. From 52b1d4d206d174c112e6ad5890062413c72b0c70 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:20:13 -0400 Subject: [PATCH 2/7] NEO-83: Add GET /game/world/combat-targets HP snapshot. Wire CombatTargetsWorldApi backed by ICombatEntityHealthStore, Bruno happy + cast defeat spine, integration tests, README and module tracking docs. --- .../Get combat targets after cast spine.bru | 115 ++++++++++++++ .../combat-targets/Get combat targets.bru | 47 ++++++ .../combat-targets/folder.bru | 8 + .../modules/E5_M1_CombatRulesEngine.md | 4 +- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E5M1-prototype-backlog.md | 6 +- docs/plans/NEO-83-implementation-plan.md | 10 +- .../Game/Combat/CombatTargetsWorldApiTests.cs | 145 ++++++++++++++++++ .../Game/Combat/CombatTargetsListDtos.cs | 32 ++++ .../Game/Combat/CombatTargetsWorldApi.cs | 43 ++++++ .../Game/Targeting/PrototypeTargetRegistry.cs | 8 + server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 19 ++- 14 files changed, 433 insertions(+), 9 deletions(-) create mode 100644 bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru create mode 100644 bruno/neon-sprawl-server/combat-targets/Get combat targets.bru create mode 100644 bruno/neon-sprawl-server/combat-targets/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatTargetsListDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru new file mode 100644 index 0000000..a01f6dd --- /dev/null +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru @@ -0,0 +1,115 @@ +meta { + name: GET combat targets after cast spine (NEO-83) + type: http + seq: 2 +} + +docs { + NEO-83 AC spine: pre-request locks alpha, casts pulse four times (3.2s between), then GET asserts alpha defeated. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId: "prototype_target_alpha" }, + jsonHeaders, + ); + + const castBody = { + schemaVersion: 1, + slotIndex: 0, + abilityId: "prototype_pulse", + targetId: "prototype_target_alpha", + }; + + const castResults = []; + for (let i = 0; i < 4; i++) { + const response = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + castBody, + jsonHeaders, + ); + const body = response.data; + if (!body?.accepted || !body?.combatResolution) { + throw new Error( + `neo83 cast ${i + 1} failed: ${JSON.stringify(body)}`, + ); + } + castResults.push(body); + if (i < 3) { + await sleep(3200); + } + } + + bru.setVar("neo83CastResults", JSON.stringify(castResults)); +} + +get { + url: {{baseUrl}}/game/world/combat-targets + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.targets.length).to.equal(2); + }); + + test("cast results stepped HP before GET", function () { + const castResults = JSON.parse(bru.getVar("neo83CastResults")); + expect(castResults.length).to.equal(4); + expect(castResults[0].combatResolution.targetRemainingHp).to.equal(75); + expect(castResults[1].combatResolution.targetRemainingHp).to.equal(50); + expect(castResults[2].combatResolution.targetRemainingHp).to.equal(25); + expect(castResults[3].combatResolution.targetRemainingHp).to.equal(0); + expect(castResults[3].combatResolution.targetDefeated).to.equal(true); + }); + + test("alpha reflects cast damage authoritatively", function () { + const body = res.getBody(); + const alpha = body.targets.find( + (x) => x.targetId === "prototype_target_alpha", + ); + expect(alpha).to.be.an("object"); + expect(alpha.currentHp).to.equal(0); + expect(alpha.maxHp).to.equal(100); + expect(alpha.defeated).to.equal(true); + }); + + test("beta unchanged after alpha-only casts", function () { + const body = res.getBody(); + const beta = body.targets.find( + (x) => x.targetId === "prototype_target_beta", + ); + expect(beta).to.be.an("object"); + expect(beta.currentHp).to.equal(100); + expect(beta.defeated).to.equal(false); + }); +} diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru new file mode 100644 index 0000000..4538346 --- /dev/null +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru @@ -0,0 +1,47 @@ +meta { + name: GET combat targets + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/game/world/combat-targets + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1 and targets array", function () { + expect(res.getStatus()).to.equal(200); + expect(res.getHeader("content-type")).to.contain("application/json"); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.targets).to.be.an("array"); + expect(body.targets.length).to.equal(2); + }); + + test("targets are ascending by targetId (ordinal)", function () { + const body = res.getBody(); + const ids = body.targets.map((x) => x.targetId); + const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + expect(ids).to.eql(sorted); + }); + + test("prototype registry targets match id order", function () { + const body = res.getBody(); + const ids = body.targets.map((x) => x.targetId); + expect(ids).to.eql([ + "prototype_target_alpha", + "prototype_target_beta", + ]); + }); + + test("both targets start at full HP on fresh server", function () { + const body = res.getBody(); + for (const row of body.targets) { + expect(row.maxHp).to.equal(100); + expect(row.currentHp).to.equal(100); + expect(row.defeated).to.equal(false); + } + }); +} diff --git a/bruno/neon-sprawl-server/combat-targets/folder.bru b/bruno/neon-sprawl-server/combat-targets/folder.bru new file mode 100644 index 0000000..6b9d9cd --- /dev/null +++ b/bruno/neon-sprawl-server/combat-targets/folder.bru @@ -0,0 +1,8 @@ +meta { + name: combat-targets +} + +docs { + NEO-83: fresh GET on seq 1; cast spine on seq 2 (~13s pre-request for four-pulse defeat). + Cross-link: bruno/neon-sprawl-server/ability-cast/ for cast-only verification. +} diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index bbcb22d..ea571d8 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M1 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08+ pending (see [dependency register](module_dependency_register.md)) | +| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-09+ pending (see [dependency register](module_dependency_register.md)) | | **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) | ## Purpose @@ -58,6 +58,8 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 a **E5M1-07 (NEO-82):** **`AbilityCastApi`** → **`CombatOperations.TryResolve`** — nested wire **`combatResolution`** on accept, per-ability catalog **`cooldownSeconds`**, **`target_defeated`** cast deny without cooldown commit ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/`. +**E5M1-08 (NEO-83):** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs in `Game/Combat/`; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. + ## Linear backlog (decomposed) Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 3d836b9..f91713e 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -56,7 +56,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | -| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-08** [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | +| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-09** [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 64b283c..581f178 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned | | E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned | -**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. +**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. ### Epic 6 — PvP Security diff --git a/docs/plans/E5M1-prototype-backlog.md b/docs/plans/E5M1-prototype-backlog.md index 0b305b1..a8279ff 100644 --- a/docs/plans/E5M1-prototype-backlog.md +++ b/docs/plans/E5M1-prototype-backlog.md @@ -262,8 +262,10 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/ **Acceptance criteria** -- [ ] GET reflects damage applied via cast route without client-side HP math. -- [ ] Defeated flag true after lethal cast sequence in Bruno. +- [x] GET reflects damage applied via cast route without client-side HP math. +- [x] Defeated flag true after lethal cast sequence in Bruno. + +**Landed ([NEO-83](https://linear.app/neon-sprawl/issue/NEO-83)):** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; cast→GET damage + defeat spine ([NEO-83-implementation-plan.md](NEO-83-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/combat-targets/`. **Client counterpart:** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) diff --git a/docs/plans/NEO-83-implementation-plan.md b/docs/plans/NEO-83-implementation-plan.md index 17cd6e3..bf82be1 100644 --- a/docs/plans/NEO-83-implementation-plan.md +++ b/docs/plans/NEO-83-implementation-plan.md @@ -45,8 +45,8 @@ ## Acceptance criteria checklist -- [ ] GET reflects damage applied via cast route without client-side HP math. -- [ ] Defeated flag true after lethal cast sequence in Bruno. +- [x] GET reflects damage applied via cast route without client-side HP math. +- [x] Defeated flag true after lethal cast sequence in Bruno. ## Technical approach @@ -135,3 +135,9 @@ Bruno spine covers manual HTTP verification; no **`docs/manual-qa/NEO-83.md`** ( - No manual QA doc; Bruno + README + API tests sufficient (server-only — NEO-85 owns Godot HUD). - Wire envelope: **`schemaVersion` 1**, **`targets`** array with **`targetId`**, **`maxHp`**, **`currentHp`**, **`defeated`**. - Always return both prototype registry targets in ascending id order; lazy-init via **`TryGet`** on read. + +## Reconciliation (implementation) + +- **`CombatTargetsWorldApi`** + **`CombatTargetsListDtos`** in **`Game/Combat/`**; **`PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder()`**. +- **3** AAA integration tests in **`CombatTargetsWorldApiTests`** (fresh GET, post-cast damage, four-pulse defeat). +- Bruno happy GET + cast→GET defeat spine; **`server/README.md`**, E5M1 backlog, E5_M1 module doc, dependency register, alignment table updated. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs new file mode 100644 index 0000000..509a929 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs @@ -0,0 +1,145 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Targeting; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public class CombatTargetsWorldApiTests +{ + /// Prototype combat targets in registry id order (ordinal). Keep in sync with Bruno. + public static readonly string[] PrototypeTargetsInIdOrder = + [ + PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeTargetRegistry.PrototypeTargetBetaId, + ]; + + private static async Task BindSlot0PulseAsync(HttpClient client) + { + var post = await client.PostAsJsonAsync( + "/game/players/dev-local-1/hotbar-loadout", + new HotbarLoadoutUpdateRequest + { + SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion, + Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }], + }); + post.EnsureSuccessStatusCode(); + } + + private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) + { + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/target/select", + new TargetSelectRequest + { + SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.SelectionApplied); + } + + private static AbilityCastRequest PulseCastRequest() => + new() + { + SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, + SlotIndex = 0, + AbilityId = PrototypeAbilityRegistry.PrototypePulse, + TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + }; + + [Fact] + public async Task GetCombatTargets_ShouldReturnSchemaV1_WithBothTargetsAtFullHp() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/game/world/combat-targets"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(CombatTargetsListResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.NotNull(body.Targets); + Assert.Equal(2, body.Targets.Count); + var ids = body.Targets.Select(static t => t.TargetId).ToList(); + Assert.Equal(PrototypeTargetsInIdOrder, ids); + + var alpha = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.MaxHp); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp); + Assert.False(alpha.Defeated); + + var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.MaxHp); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); + Assert.False(beta.Defeated); + } + + [Fact] + public async Task GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var castResponse = await client.PostAsJsonAsync( + "/game/players/dev-local-1/ability-cast", + PulseCastRequest()); + castResponse.EnsureSuccessStatusCode(); + // Act + var response = await client.GetAsync("/game/world/combat-targets"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); + Assert.Equal(75, alpha.CurrentHp); + Assert.False(alpha.Defeated); + var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); + Assert.False(beta.Defeated); + } + + [Fact] + public async Task GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnAlpha() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = PulseCastRequest(); + for (var i = 0; i < 4; i++) + { + var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + castResponse.EnsureSuccessStatusCode(); + if (i < 3) + { + factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + } + } + + // Act + var response = await client.GetAsync("/game/world/combat-targets"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); + Assert.Equal(0, alpha.CurrentHp); + Assert.True(alpha.Defeated); + var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); + Assert.False(beta.Defeated); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetsListDtos.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetsListDtos.cs new file mode 100644 index 0000000..4c482d2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetsListDtos.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Combat; + +/// JSON body for GET /game/world/combat-targets (NEO-83). +public sealed class CombatTargetsListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Prototype combat dummies ordered by stable targetId (ordinal). + [JsonPropertyName("targets")] + public required IReadOnlyList Targets { get; init; } +} + +/// One row in the read-only combat target HP projection. +public sealed class CombatTargetJson +{ + [JsonPropertyName("targetId")] + public required string TargetId { get; init; } + + [JsonPropertyName("maxHp")] + public required int MaxHp { get; init; } + + [JsonPropertyName("currentHp")] + public required int CurrentHp { get; init; } + + [JsonPropertyName("defeated")] + public required bool Defeated { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs new file mode 100644 index 0000000..1247817 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs @@ -0,0 +1,43 @@ +using NeonSprawl.Server.Game.Targeting; + +namespace NeonSprawl.Server.Game.Combat; + +/// Maps GET /game/world/combat-targets (NEO-83). +public static class CombatTargetsWorldApi +{ + public static WebApplication MapCombatTargetsWorldApi(this WebApplication app) + { + app.MapGet( + "/game/world/combat-targets", + (ICombatEntityHealthStore healthStore) => + { + var ids = PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder(); + var targets = new List(ids.Count); + foreach (var id in ids) + { + if (!healthStore.TryGet(id, out var snapshot)) + { + continue; + } + + targets.Add( + new CombatTargetJson + { + TargetId = snapshot.TargetId, + MaxHp = snapshot.MaxHp, + CurrentHp = snapshot.CurrentHp, + Defeated = snapshot.Defeated, + }); + } + + return Results.Json( + new CombatTargetsListResponse + { + SchemaVersion = CombatTargetsListResponse.CurrentSchemaVersion, + Targets = targets, + }); + }); + + return app; + } +} diff --git a/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs b/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs index e477ff9..0442d60 100644 --- a/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs +++ b/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs @@ -30,6 +30,14 @@ public static class PrototypeTargetRegistry public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) => ById.TryGetValue(targetIdLowercase, out entry); + + /// All prototype combat target ids in ascending ordinal order (NEO-83). + public static IReadOnlyList GetPrototypeTargetIdsInOrder() + { + var ids = ById.Keys.ToList(); + ids.Sort(StringComparer.Ordinal); + return ids; + } } /// Horizontal reach on X/Z; inclusive boundary (same as NEO-9). diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 7e52634..71368f9 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -49,6 +49,7 @@ app.MapSkillDefinitionsWorldApi(); app.MapItemDefinitionsWorldApi(); app.MapRecipeDefinitionsWorldApi(); app.MapAbilityDefinitionsWorldApi(); +app.MapCombatTargetsWorldApi(); app.MapResourceNodeDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); diff --git a/server/README.md b/server/README.md index 5efec5a..6b23c61 100644 --- a/server/README.md +++ b/server/README.md @@ -110,9 +110,24 @@ Server-owned HP for prototype combat dummies lives in **`Game/Combat/`** as **`I | **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id. | | **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Re-hit on defeated targets still applies at store layer (HP stays 0); structured deny is **NEO-81** (`CombatOperations`). | | **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives a dummy for tests/fixtures — not exposed over HTTP in Slice 1. | -| **HTTP read** | **`GET /game/world/combat-targets`** is **NEO-83**; cast wiring is **NEO-82**. | +| **HTTP read** | **`GET /game/world/combat-targets`** (NEO-83) — authoritative HP snapshot for client HUD; cast wiring is **NEO-82**. | -Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md). +Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md); HTTP read: [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83). + +## Combat targets snapshot (NEO-83) + +**`GET /game/world/combat-targets`** returns a versioned JSON body (`schemaVersion` **1**, **`targets`**) backed by **`ICombatEntityHealthStore`** — the same authoritative HP rows mutated by cast resolution (no client-side damage math). Each row includes **`targetId`**, **`maxHp`**, **`currentHp`**, and **`defeated`**. Both **`PrototypeTargetRegistry`** dummies are always returned in ascending **`targetId`** order; lazy-init on first read matches NEO-80. Plan: [NEO-83 implementation plan](../../docs/plans/NEO-83-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-targets/`. + +| Field | Meaning | +|-------|---------| +| **`targetId`** | Lowercase prototype registry id (`prototype_target_alpha`, `prototype_target_beta`). | +| **`maxHp`** | Frozen prototype max (**100**). | +| **`currentHp`** | Authoritative HP after cast-applied damage; floored at zero. | +| **`defeated`** | **`true`** when **`currentHp`** is **0**. | + +```bash +curl -sS -i "http://localhost:5253/game/world/combat-targets" +``` ## Combat engine (NEO-81) From aafa5e472a9583625b8993eb5e700ee02682aa08 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:22:13 -0400 Subject: [PATCH 3/7] NEO-83: Add code review for combat-targets GET snapshot. --- docs/reviews/2026-05-25-NEO-83.md | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/reviews/2026-05-25-NEO-83.md diff --git a/docs/reviews/2026-05-25-NEO-83.md b/docs/reviews/2026-05-25-NEO-83.md new file mode 100644 index 0000000..f4c444d --- /dev/null +++ b/docs/reviews/2026-05-25-NEO-83.md @@ -0,0 +1,61 @@ +# Code review — NEO-83 GET /game/world/combat-targets HP snapshot + +**Date:** 2026-05-25 +**Scope:** Branch `NEO-83-combat-targets-snapshot` · commits `902e038`–`52b1d4d` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-83 delivers **E5M1-08**: a read-only **`GET /game/world/combat-targets`** route backed by **`ICombatEntityHealthStore`**, returning `schemaVersion` **1** and a **`targets`** array (`targetId`, `maxHp`, `currentHp`, `defeated`) for both prototype dummies in ascending id order. Handler flow mirrors NEO-78 world GET patterns; **`PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder()`** centralizes enumeration. Three AAA integration tests cover fresh snapshot, post-cast damage reflection, and four-pulse defeat; Bruno happy GET + cast→GET defeat spine (with HP stepping assertions on cast bodies) satisfy AC. Module tracking docs, E5M1 backlog, and **`server/README.md`** were updated in the implementation commit — better doc sync than prior E5.M1 slices. Residual risk is low — server-only read projection with no client change (NEO-85 owns HUD). + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-83-implementation-plan.md`](../plans/NEO-83-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, expected snapshot table, and reconciliation align with code. | +| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-08** | **Matches** — AC checked, landed note added. | +| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites E5M1-08 NEO-83 landed; **Related implementation slices** entry added. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-83 landed. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row cites combat-targets GET + NEO-83 plan link. | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-owned HP snapshot; no client-side damage math. | +| [`docs/decomposition/modules/pvp_combat_integration.md`](../decomposition/modules/pvp_combat_integration.md) | **N/A** — prototype NPC targets only. | +| [`server/README.md`](../../server/README.md) | **Matches** — combat-targets section, field table, curl example; NEO-80 HTTP read row updated. | +| Full-stack epic decomposition | **Matches** — plan documents NEO-85 client counterpart; no Godot change in scope. | +| Manual QA | **N/A** — kickoff skip; Bruno + README + API tests sufficient. | + +## Blocking issues + +None. + +## Suggestions + +1. **Silent omission on `TryGet` miss** — In `CombatTargetsWorldApi`, registry ids that fail `healthStore.TryGet` are skipped via `continue`, which could return a partial `targets` array without error. For prototype registry ids, `InMemoryCombatEntityHealthStore.TryGet` always lazy-inits and succeeds; consider removing the skip or logging/asserting in dev so a store regression cannot silently drop HUD rows. + +2. **Bruno one-pulse GET smoke (optional)** — Plan §7 described a spine asserting alpha **`currentHp` 75** after one pulse; implementation consolidates into the four-pulse defeat spine only. Integration test **`GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha`** covers the gap — optional separate Bruno request if manual QA wants a fast (~0s) cast→GET check without cooldown waits. + +## Nits + +- Nit: **`BindSlot0PulseAsync` / `LockPrototypeTargetAlphaAsync` / `PulseCastRequest`** duplicate helpers in `AbilityCastApiTests` — acceptable for story scope; optional shared test fixture if a third combat cast consumer appears. + +- Nit: **`GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha`** uses `EnsureSuccessStatusCode` on cast but does not assert `combatResolution.targetRemainingHp == 75` in Arrange — would tighten cast→GET coherence (mirror defeat test’s cast-body checks). + +- Nit: Plan §6 mentions teleport-in-range setup like some Bruno flows; cast tests on this branch omit move/teleport and still pass (same as `AbilityCastApiTests` default) — no action unless range gates tighten. + +## Verification + +```bash +cd /home/don/neon-sprawl/server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \ + --filter "FullyQualifiedName~CombatTargetsWorldApiTests" +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj +``` + +**CombatTargetsWorldApiTests:** 3/3 passed (local run). + +**Full suite:** unrelated failures observed locally (e.g. crafting/inventory tests — flaky or environment-specific; not touched by this diff). Confirm CI on PR before merge. + +**Bruno (manual):** Run `bruno/neon-sprawl-server/combat-targets/` — seq 1 fresh GET; seq 2 cast spine (~13s pre-request for four-pulse defeat + HP stepping assertions). From 50a2c9ca9dd5fecb051a889b08adb82e1fcbbd5f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:23:32 -0400 Subject: [PATCH 4/7] NEO-83: Address code review follow-ups for combat-targets GET. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail fast on health store TryGet miss, tighten one-pulse integration test assertions, add fast Bruno cast→GET smoke, update review doc. --- .../Get combat targets after cast spine.bru | 2 +- .../Get combat targets after one cast.bru | 90 +++++++++++++++++++ .../combat-targets/folder.bru | 2 +- docs/plans/NEO-83-implementation-plan.md | 3 +- docs/reviews/2026-05-25-NEO-83.md | 14 +-- .../Game/Combat/CombatTargetsWorldApiTests.cs | 11 ++- .../Game/Combat/CombatTargetsWorldApi.cs | 3 +- 7 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru index a01f6dd..a4bf633 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru @@ -1,7 +1,7 @@ meta { name: GET combat targets after cast spine (NEO-83) type: http - seq: 2 + seq: 3 } docs { diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru new file mode 100644 index 0000000..824a197 --- /dev/null +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru @@ -0,0 +1,90 @@ +meta { + name: GET combat targets after one cast (NEO-83) + type: http + seq: 2 +} + +docs { + Fast cast→GET smoke: one pulse on alpha, no cooldown waits. Four-pulse defeat spine is seq 3. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/hotbar-loadout`, + { + schemaVersion: 1, + slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + }, + jsonHeaders, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/target/select`, + { schemaVersion: 1, targetId: "prototype_target_alpha" }, + jsonHeaders, + ); + + const castResponse = await axios.post( + `${baseUrl}/game/players/${playerId}/ability-cast`, + { + schemaVersion: 1, + slotIndex: 0, + abilityId: "prototype_pulse", + targetId: "prototype_target_alpha", + }, + jsonHeaders, + ); + + const castBody = castResponse.data; + if (!castBody?.accepted || !castBody?.combatResolution) { + throw new Error(`neo83 one-pulse cast failed: ${JSON.stringify(castBody)}`); + } + + bru.setVar("neo83OnePulseCast", JSON.stringify(castBody)); +} + +get { + url: {{baseUrl}}/game/world/combat-targets + body: none + auth: none +} + +tests { + test("one-pulse cast body shows 75 HP remaining", function () { + const castBody = JSON.parse(bru.getVar("neo83OnePulseCast")); + expect(castBody.combatResolution.targetRemainingHp).to.equal(75); + expect(castBody.combatResolution.targetDefeated).to.equal(false); + }); + + test("GET alpha reflects cast damage authoritatively", function () { + const body = res.getBody(); + const alpha = body.targets.find( + (x) => x.targetId === "prototype_target_alpha", + ); + expect(alpha).to.be.an("object"); + expect(alpha.currentHp).to.equal(75); + expect(alpha.maxHp).to.equal(100); + expect(alpha.defeated).to.equal(false); + }); + + test("beta unchanged after alpha-only cast", function () { + const body = res.getBody(); + const beta = body.targets.find( + (x) => x.targetId === "prototype_target_beta", + ); + expect(beta).to.be.an("object"); + expect(beta.currentHp).to.equal(100); + expect(beta.defeated).to.equal(false); + }); +} diff --git a/bruno/neon-sprawl-server/combat-targets/folder.bru b/bruno/neon-sprawl-server/combat-targets/folder.bru index 6b9d9cd..935d26f 100644 --- a/bruno/neon-sprawl-server/combat-targets/folder.bru +++ b/bruno/neon-sprawl-server/combat-targets/folder.bru @@ -3,6 +3,6 @@ meta { } docs { - NEO-83: fresh GET on seq 1; cast spine on seq 2 (~13s pre-request for four-pulse defeat). + NEO-83: fresh GET on seq 1; one-pulse cast→GET on seq 2 (~0s); four-pulse defeat spine on seq 3 (~13s pre-request). Cross-link: bruno/neon-sprawl-server/ability-cast/ for cast-only verification. } diff --git a/docs/plans/NEO-83-implementation-plan.md b/docs/plans/NEO-83-implementation-plan.md index bf82be1..c6ab6d9 100644 --- a/docs/plans/NEO-83-implementation-plan.md +++ b/docs/plans/NEO-83-implementation-plan.md @@ -73,7 +73,8 @@ 7. **Bruno (`bruno/neon-sprawl-server/combat-targets/`):** - **`Get combat targets.bru`** — assert 200, **`schemaVersion` 1**, **`targets.length === 2`**, ascending ids, both at full HP on fresh server. - - **`Get combat targets after cast spine.bru`** — pre-request: move + hotbar + lock alpha + one pulse cast; GET asserts alpha **`currentHp` 75**; optional extended pre-request for four casts + **`defeated` true** on alpha (AC spine). + - **`Get combat targets after one cast.bru`** — pre-request: move + hotbar + lock alpha + one pulse cast; GET asserts alpha **`currentHp` 75**. + - **`Get combat targets after cast spine.bru`** — pre-request: four pulse casts + defeat; GET asserts alpha **`defeated` true** (AC spine). - **`folder.bru`** — run order note; cross-link ability-cast folder. 8. **Docs:** Add **`server/README.md`** **`GET /game/world/combat-targets`** section with field table + curl example; update NEO-80 health store row (“HTTP read” no longer pending). Reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-08 checkboxes and [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md) when landed. diff --git a/docs/reviews/2026-05-25-NEO-83.md b/docs/reviews/2026-05-25-NEO-83.md index f4c444d..3dcfb5a 100644 --- a/docs/reviews/2026-05-25-NEO-83.md +++ b/docs/reviews/2026-05-25-NEO-83.md @@ -4,6 +4,8 @@ **Scope:** Branch `NEO-83-combat-targets-snapshot` · commits `902e038`–`52b1d4d` vs `origin/main` **Base:** `origin/main` +**Follow-up:** Code review suggestions and nits addressed in follow-up commit on `NEO-83-combat-targets-snapshot`. + ## Verdict **Approve with nits** @@ -33,17 +35,17 @@ None. ## Suggestions -1. **Silent omission on `TryGet` miss** — In `CombatTargetsWorldApi`, registry ids that fail `healthStore.TryGet` are skipped via `continue`, which could return a partial `targets` array without error. For prototype registry ids, `InMemoryCombatEntityHealthStore.TryGet` always lazy-inits and succeeds; consider removing the skip or logging/asserting in dev so a store regression cannot silently drop HUD rows. +1. ~~**Silent omission on `TryGet` miss** — In `CombatTargetsWorldApi`, registry ids that fail `healthStore.TryGet` are skipped via `continue`, which could return a partial `targets` array without error. For prototype registry ids, `InMemoryCombatEntityHealthStore.TryGet` always lazy-inits and succeeds; consider removing the skip or logging/asserting in dev so a store regression cannot silently drop HUD rows.~~ **Done.** — `TryGet` miss throws `InvalidOperationException` instead of skipping. -2. **Bruno one-pulse GET smoke (optional)** — Plan §7 described a spine asserting alpha **`currentHp` 75** after one pulse; implementation consolidates into the four-pulse defeat spine only. Integration test **`GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha`** covers the gap — optional separate Bruno request if manual QA wants a fast (~0s) cast→GET check without cooldown waits. +2. ~~**Bruno one-pulse GET smoke (optional)** — Plan §7 described a spine asserting alpha **`currentHp` 75** after one pulse; implementation consolidates into the four-pulse defeat spine only. Integration test **`GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha`** covers the gap — optional separate Bruno request if manual QA wants a fast (~0s) cast→GET check without cooldown waits.~~ **Done.** — Added `Get combat targets after one cast.bru` (seq 2); defeat spine moved to seq 3. ## Nits -- Nit: **`BindSlot0PulseAsync` / `LockPrototypeTargetAlphaAsync` / `PulseCastRequest`** duplicate helpers in `AbilityCastApiTests` — acceptable for story scope; optional shared test fixture if a third combat cast consumer appears. +- Nit: **`BindSlot0PulseAsync` / `LockPrototypeTargetAlphaAsync` / `PulseCastRequest`** duplicate helpers in `AbilityCastApiTests` — acceptable for story scope; optional shared test fixture if a third combat cast consumer appears. **Deferred** — no third consumer yet. -- Nit: **`GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha`** uses `EnsureSuccessStatusCode` on cast but does not assert `combatResolution.targetRemainingHp == 75` in Arrange — would tighten cast→GET coherence (mirror defeat test’s cast-body checks). +- ~~Nit: **`GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha`** uses `EnsureSuccessStatusCode` on cast but does not assert `combatResolution.targetRemainingHp == 75` in Arrange — would tighten cast→GET coherence (mirror defeat test’s cast-body checks).~~ **Done.** — Assert block now verifies cast `combatResolution.targetRemainingHp` before GET row checks. -- Nit: Plan §6 mentions teleport-in-range setup like some Bruno flows; cast tests on this branch omit move/teleport and still pass (same as `AbilityCastApiTests` default) — no action unless range gates tighten. +- Nit: Plan §6 mentions teleport-in-range setup like some Bruno flows; cast tests on this branch omit move/teleport and still pass (same as `AbilityCastApiTests` default) — no action unless range gates tighten. **No action** — per review. ## Verification @@ -58,4 +60,4 @@ dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj **Full suite:** unrelated failures observed locally (e.g. crafting/inventory tests — flaky or environment-specific; not touched by this diff). Confirm CI on PR before merge. -**Bruno (manual):** Run `bruno/neon-sprawl-server/combat-targets/` — seq 1 fresh GET; seq 2 cast spine (~13s pre-request for four-pulse defeat + HP stepping assertions). +**Bruno (manual):** Run `bruno/neon-sprawl-server/combat-targets/` — seq 1 fresh GET; seq 2 one-pulse cast→GET (~0s); seq 3 four-pulse defeat spine (~13s pre-request + HP stepping assertions). diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs index 509a929..5509ef7 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs @@ -94,11 +94,18 @@ public class CombatTargetsWorldApiTests "/game/players/dev-local-1/ability-cast", PulseCastRequest()); castResponse.EnsureSuccessStatusCode(); + var castBody = await castResponse.Content.ReadFromJsonAsync(); + // Act var response = await client.GetAsync("/game/world/combat-targets"); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.NotNull(castBody); + Assert.True(castBody!.Accepted); + Assert.NotNull(castBody.CombatResolution); + Assert.Equal(75, castBody.CombatResolution!.TargetRemainingHp); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); Assert.Equal(75, alpha.CurrentHp); diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs index 1247817..ef1e40c 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs @@ -17,7 +17,8 @@ public static class CombatTargetsWorldApi { if (!healthStore.TryGet(id, out var snapshot)) { - continue; + throw new InvalidOperationException( + $"Prototype combat target '{id}' is registered but missing from {nameof(ICombatEntityHealthStore)}."); } targets.Add( From 24eb355a26616c1b4926bc80a513787d544cb9d7 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:23:41 -0400 Subject: [PATCH 5/7] NEO-83: Reconcile plan with code review Bruno follow-up. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document one-pulse cast→GET smoke request and review-driven handler/test changes. --- docs/plans/NEO-83-implementation-plan.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plans/NEO-83-implementation-plan.md b/docs/plans/NEO-83-implementation-plan.md index c6ab6d9..a5390f6 100644 --- a/docs/plans/NEO-83-implementation-plan.md +++ b/docs/plans/NEO-83-implementation-plan.md @@ -97,6 +97,7 @@ | `server/NeonSprawl.Server/Game/Combat/CombatTargetsListDtos.cs` | Response envelope + row JSON types. | | `server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs` | AAA integration tests: fresh, post-cast, defeat. | | `bruno/neon-sprawl-server/combat-targets/Get combat targets.bru` | Bruno happy path. | +| `bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru` | Fast one-pulse cast→GET smoke (code review follow-up). | | `bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru` | Bruno cast → GET damage + defeat AC spine. | | `bruno/neon-sprawl-server/combat-targets/folder.bru` | Folder docs + run order. | | `docs/plans/NEO-83-implementation-plan.md` | This plan. | @@ -141,4 +142,5 @@ Bruno spine covers manual HTTP verification; no **`docs/manual-qa/NEO-83.md`** ( - **`CombatTargetsWorldApi`** + **`CombatTargetsListDtos`** in **`Game/Combat/`**; **`PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder()`**. - **3** AAA integration tests in **`CombatTargetsWorldApiTests`** (fresh GET, post-cast damage, four-pulse defeat). -- Bruno happy GET + cast→GET defeat spine; **`server/README.md`**, E5M1 backlog, E5_M1 module doc, dependency register, alignment table updated. +- Bruno happy GET + one-pulse cast→GET smoke + four-pulse defeat spine; **`server/README.md`**, E5M1 backlog, E5_M1 module doc, dependency register, alignment table updated. +- Code review follow-up: **`TryGet` miss throws** instead of silent skip; one-pulse test asserts cast **`combatResolution`** coherence. From ea13b05196d75a29c12ccd66a320feb9830a52c5 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:28:13 -0400 Subject: [PATCH 6/7] NEO-83: Fix Bruno CI combat-targets HP state pollution. Add dev combat-targets fixture reset endpoint, Bruno pre-request helper, and enable fixture in CI Bruno step so combat-targets tests run after ability-cast defeat spine. --- .github/workflows/dotnet.yml | 1 + .../Get combat targets after cast spine.bru | 3 + .../Get combat targets after one cast.bru | 3 + .../combat-targets/Get combat targets.bru | 5 ++ .../combat-targets/folder.bru | 3 +- .../scripts/combat-targets-reset-helper.js | 26 ++++++++ .../Combat/CombatTargetFixtureApiTests.cs | 64 +++++++++++++++++++ .../Game/Combat/CombatTargetFixtureApi.cs | 36 +++++++++++ .../Game/Combat/CombatTargetFixtureDtos.cs | 19 ++++++ .../Game/PositionState/GamePositionOptions.cs | 3 + server/NeonSprawl.Server/Program.cs | 7 ++ .../appsettings.Development.json | 3 +- server/README.md | 2 + 13 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js create mode 100644 server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs create mode 100644 server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 5d0d606..f6cca95 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -92,6 +92,7 @@ jobs: env: ConnectionStrings__NeonSprawl: >- Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev + Game__EnableCombatTargetFixtureApi: "true" run: | set -euo pipefail dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \ diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru index a4bf633..ec5ad8f 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru @@ -10,6 +10,7 @@ docs { script:pre-request { const axios = require("axios"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; @@ -18,6 +19,8 @@ script:pre-request { return new Promise((resolve) => setTimeout(resolve, ms)); } + await resetPrototypeCombatTargets(bru); + await axios.post( `${baseUrl}/game/players/${playerId}/move`, { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru index 824a197..77578fa 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru @@ -10,10 +10,13 @@ docs { script:pre-request { const axios = require("axios"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + await resetPrototypeCombatTargets(bru); + await axios.post( `${baseUrl}/game/players/${playerId}/move`, { schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } }, diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru index 4538346..aaf7080 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru @@ -4,6 +4,11 @@ meta { seq: 1 } +script:pre-request { + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); + await resetPrototypeCombatTargets(bru); +} + get { url: {{baseUrl}}/game/world/combat-targets body: none diff --git a/bruno/neon-sprawl-server/combat-targets/folder.bru b/bruno/neon-sprawl-server/combat-targets/folder.bru index 935d26f..8e04cd1 100644 --- a/bruno/neon-sprawl-server/combat-targets/folder.bru +++ b/bruno/neon-sprawl-server/combat-targets/folder.bru @@ -3,6 +3,7 @@ meta { } docs { - NEO-83: fresh GET on seq 1; one-pulse cast→GET on seq 2 (~0s); four-pulse defeat spine on seq 3 (~13s pre-request). + NEO-83: fresh GET on seq 1 (~reset fixture); one-pulse cast→GET on seq 2 (~0s); four-pulse defeat spine on seq 3 (~13s pre-request). + Pre-request scripts call POST /game/__dev/combat-targets-fixture so CI survives ability-cast folder pollution. Cross-link: bruno/neon-sprawl-server/ability-cast/ for cast-only verification. } diff --git a/bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js b/bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js new file mode 100644 index 0000000..55cea7b --- /dev/null +++ b/bruno/neon-sprawl-server/scripts/combat-targets-reset-helper.js @@ -0,0 +1,26 @@ +const axios = require("axios"); + +function resolveConfig(bru) { + return { + baseUrl: bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"), + jsonHeaders: { headers: { "Content-Type": "application/json" } }, + }; +} + +async function resetPrototypeCombatTargets(bru) { + const { baseUrl, jsonHeaders } = resolveConfig(bru); + const response = await axios.post( + `${baseUrl}/game/__dev/combat-targets-fixture`, + { schemaVersion: 1 }, + jsonHeaders, + ); + if (response.status !== 200 || response.data?.applied !== true) { + throw new Error( + `combat-targets fixture reset failed: ${response.status} ${JSON.stringify(response.data)}`, + ); + } +} + +module.exports = { + resetPrototypeCombatTargets, +}; diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs new file mode 100644 index 0000000..2e89d94 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Targeting; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public sealed class CombatTargetFixtureApiTests +{ + private const string FixturePath = "/game/__dev/combat-targets-fixture"; + + [Fact] + public async Task PostCombatTargetFixture_ShouldResetPrototypeTargets_WhenEnabled() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var store = factory.Services.GetRequiredService(); + _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); + + // Act + var response = await client.PostAsJsonAsync( + FixturePath, + new CombatTargetFixtureRequest + { + SchemaVersion = CombatTargetFixtureRequest.CurrentSchemaVersion, + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Applied); + store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var alpha); + Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp); + Assert.False(alpha.Defeated); + } + + [Fact] + public async Task PostCombatTargetFixture_ShouldReturnNotFound_WhenRouteNotRegistered() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.WithWebHostBuilder(b => + { + b.UseEnvironment("Production"); + b.UseSetting("Game:EnableCombatTargetFixtureApi", "false"); + }).CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + FixturePath, + new CombatTargetFixtureRequest + { + SchemaVersion = CombatTargetFixtureRequest.CurrentSchemaVersion, + }); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs new file mode 100644 index 0000000..16b92e4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -0,0 +1,36 @@ +using NeonSprawl.Server.Game.Targeting; + +namespace NeonSprawl.Server.Game.Combat; + +/// Dev-only route to reset prototype combat dummy HP for Bruno and manual QA (NEO-83). +public static class CombatTargetFixtureApi +{ + public static WebApplication MapCombatTargetFixtureApi(this WebApplication app) + { + app.MapPost( + "/game/__dev/combat-targets-fixture", + (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore) => + { + if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + foreach (var id in PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder()) + { + if (!healthStore.TryResetToFull(id, out _)) + { + return Results.NotFound(); + } + } + + return Results.Json( + new CombatTargetFixtureResponse + { + Applied = true, + }); + }); + + return app; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs new file mode 100644 index 0000000..1af4c8e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureDtos.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Combat; + +/// JSON body for POST /game/__dev/combat-targets-fixture (NEO-83 Bruno/QA). +public sealed class CombatTargetFixtureRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } +} + +/// JSON response when the dev combat-target fixture applies. +public sealed class CombatTargetFixtureResponse +{ + [JsonPropertyName("applied")] + public required bool Applied { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs index 334b824..4a0d345 100644 --- a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs @@ -13,6 +13,9 @@ public sealed class GamePositionOptions /// When true, maps POST …/__dev/mastery-fixture for Bruno/manual QA state setup (also enabled in Development). public bool EnableMasteryFixtureApi { get; set; } + /// When true, maps POST /game/__dev/combat-targets-fixture to reset prototype dummy HP (also enabled in Development/Testing). + public bool EnableCombatTargetFixtureApi { get; set; } + /// World position for the dev player at process start. public DefaultPositionOptions DefaultPosition { get; set; } = new(); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 71368f9..b1889ed 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -62,6 +62,13 @@ if (app.Environment.IsDevelopment() || app.MapMasteryFixtureApi(); } +if (app.Environment.IsDevelopment() || + app.Environment.IsEnvironment("Testing") || + app.Configuration.GetValue("Game:EnableCombatTargetFixtureApi")) +{ + app.MapCombatTargetFixtureApi(); +} + app.MapTargetingApi(); app.MapHotbarLoadoutApi(); app.MapCooldownSnapshotApi(); diff --git a/server/NeonSprawl.Server/appsettings.Development.json b/server/NeonSprawl.Server/appsettings.Development.json index d37883a..ceb318d 100644 --- a/server/NeonSprawl.Server/appsettings.Development.json +++ b/server/NeonSprawl.Server/appsettings.Development.json @@ -6,6 +6,7 @@ } }, "Game": { - "EnableMasteryFixtureApi": true + "EnableMasteryFixtureApi": true, + "EnableCombatTargetFixtureApi": true } } diff --git a/server/README.md b/server/README.md index 6b23c61..7d74509 100644 --- a/server/README.md +++ b/server/README.md @@ -129,6 +129,8 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m curl -sS -i "http://localhost:5253/game/world/combat-targets" ``` +**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeTargetRegistry`** dummies to full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. + ## Combat engine (NEO-81) **`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept. From 7d325eec4c6be3c9d307703d692cb88fd1dc2dec Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 11:31:02 -0400 Subject: [PATCH 7/7] NEO-83: Use slot 1 in Bruno defeat spine to avoid seq 2 cooldown. Combat-targets fixture resets HP only; seq 2 leaves slot 0 on cooldown so the four-pulse spine follows NEO-82 and binds slot 1. --- .../combat-targets/Get combat targets after cast spine.bru | 6 +++--- bruno/neon-sprawl-server/combat-targets/folder.bru | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru index ec5ad8f..dc93f44 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru @@ -5,7 +5,7 @@ meta { } docs { - NEO-83 AC spine: pre-request locks alpha, casts pulse four times (3.2s between), then GET asserts alpha defeated. + NEO-83 AC spine: slot 1 (seq 2 one-pulse uses slot 0 and leaves cooldown). Pre-request locks alpha, casts pulse four times (3.2s between), then GET asserts alpha defeated. } script:pre-request { @@ -31,7 +31,7 @@ script:pre-request { `${baseUrl}/game/players/${playerId}/hotbar-loadout`, { schemaVersion: 1, - slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], + slots: [{ slotIndex: 1, abilityId: "prototype_pulse" }], }, jsonHeaders, ); @@ -44,7 +44,7 @@ script:pre-request { const castBody = { schemaVersion: 1, - slotIndex: 0, + slotIndex: 1, abilityId: "prototype_pulse", targetId: "prototype_target_alpha", }; diff --git a/bruno/neon-sprawl-server/combat-targets/folder.bru b/bruno/neon-sprawl-server/combat-targets/folder.bru index 8e04cd1..a556fa7 100644 --- a/bruno/neon-sprawl-server/combat-targets/folder.bru +++ b/bruno/neon-sprawl-server/combat-targets/folder.bru @@ -3,7 +3,7 @@ meta { } docs { - NEO-83: fresh GET on seq 1 (~reset fixture); one-pulse cast→GET on seq 2 (~0s); four-pulse defeat spine on seq 3 (~13s pre-request). + NEO-83: fresh GET on seq 1 (~reset fixture); one-pulse cast→GET on seq 2 slot 0 (~0s); four-pulse defeat spine on seq 3 slot 1 (~13s pre-request; seq 2 leaves slot 0 on cooldown). Pre-request scripts call POST /game/__dev/combat-targets-fixture so CI survives ability-cast folder pollution. Cross-link: bruno/neon-sprawl-server/ability-cast/ for cast-only verification. }