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.pull/117/head
parent
902e03848c
commit
52b1d4d206
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>Prototype combat targets in registry id order (ordinal). Keep in sync with Bruno.</summary>
|
||||
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<TargetSelectResponse>();
|
||||
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<CombatTargetsListResponse>();
|
||||
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<CombatTargetsListResponse>();
|
||||
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<CombatTargetsListResponse>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/world/combat-targets</c> (NEO-83).</summary>
|
||||
public sealed class CombatTargetsListResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
/// <summary>Prototype combat dummies ordered by stable <c>targetId</c> (ordinal).</summary>
|
||||
[JsonPropertyName("targets")]
|
||||
public required IReadOnlyList<CombatTargetJson> Targets { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One row in the read-only combat target HP projection.</summary>
|
||||
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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Maps <c>GET /game/world/combat-targets</c> (NEO-83).</summary>
|
||||
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<CombatTargetJson>(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,14 @@ public static class PrototypeTargetRegistry
|
|||
|
||||
public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) =>
|
||||
ById.TryGetValue(targetIdLowercase, out entry);
|
||||
|
||||
/// <summary>All prototype combat target ids in ascending ordinal order (NEO-83).</summary>
|
||||
public static IReadOnlyList<string> GetPrototypeTargetIdsInOrder()
|
||||
{
|
||||
var ids = ById.Keys.ToList();
|
||||
ids.Sort(StringComparer.Ordinal);
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
/// <param name="LockRadius">Horizontal reach on X/Z; inclusive boundary (same as NEO-9).</param>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ app.MapSkillDefinitionsWorldApi();
|
|||
app.MapItemDefinitionsWorldApi();
|
||||
app.MapRecipeDefinitionsWorldApi();
|
||||
app.MapAbilityDefinitionsWorldApi();
|
||||
app.MapCombatTargetsWorldApi();
|
||||
app.MapResourceNodeDefinitionsWorldApi();
|
||||
app.MapPlayerInventoryApi();
|
||||
app.MapPlayerCraftApi();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue