Compare commits
21 Commits
dda0dadf56
...
25876a16fd
| Author | SHA1 | Date |
|---|---|---|
|
|
25876a16fd | |
|
|
7d325eec4c | |
|
|
ea13b05196 | |
|
|
24eb355a26 | |
|
|
50a2c9ca9d | |
|
|
aafa5e472a | |
|
|
52b1d4d206 | |
|
|
902e03848c | |
|
|
157564e810 | |
|
|
3e0d370677 | |
|
|
71d93ee1fd | |
|
|
52bf9fc4bf | |
|
|
322798b2ef | |
|
|
0b40ae3598 | |
|
|
e8be100f80 | |
|
|
4126ac9bcc | |
|
|
b8d87bbb54 | |
|
|
c7aa75ebc5 | |
|
|
d63e448a08 | |
|
|
ca15e7a689 | |
|
|
849c34f460 |
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -8,13 +8,27 @@ 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,
|
||||
target: { x: -5, y: 0.9, z: -5 },
|
||||
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }],
|
||||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
jsonHeaders,
|
||||
);
|
||||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/target/select`,
|
||||
{ schemaVersion: 1, targetId: "prototype_target_beta" },
|
||||
jsonHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +43,7 @@ body:json {
|
|||
"schemaVersion": 1,
|
||||
"slotIndex": 0,
|
||||
"abilityId": "prototype_pulse",
|
||||
"targetId": "prototype_target_alpha"
|
||||
"targetId": "prototype_target_beta"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,9 +54,15 @@ tests {
|
|||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("accepted true", function () {
|
||||
test("accepted true with combat resolution", function () {
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(true);
|
||||
expect(body.combatResolution).to.be.an("object");
|
||||
expect(body.combatResolution.abilityId).to.equal("prototype_pulse");
|
||||
expect(body.combatResolution.targetId).to.equal("prototype_target_beta");
|
||||
expect(body.combatResolution.damageDealt).to.equal(25);
|
||||
expect(body.combatResolution.targetRemainingHp).to.equal(75);
|
||||
expect(body.combatResolution.targetDefeated).to.equal(false);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
meta {
|
||||
name: POST cast lock pulse defeat spine (NEO-82)
|
||||
type: http
|
||||
seq: 30
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks alpha, casts pulse four times (3.2s between), fifth request expects target_defeated deny.
|
||||
}
|
||||
|
||||
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: 1, 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: 1,
|
||||
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(
|
||||
`neo82 cast ${i + 1} failed: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
castResults.push(body);
|
||||
if (i < 3) {
|
||||
await sleep(3200);
|
||||
}
|
||||
}
|
||||
|
||||
bru.setVar("neo82CastResults", JSON.stringify(castResults));
|
||||
await sleep(3200);
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"slotIndex": 1,
|
||||
"abilityId": "prototype_pulse",
|
||||
"targetId": "prototype_target_alpha"
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("pulse HP stepping on casts 1-4", function () {
|
||||
const casts = JSON.parse(bru.getVar("neo82CastResults"));
|
||||
expect(casts[0].combatResolution.targetRemainingHp).to.equal(75);
|
||||
expect(casts[1].combatResolution.targetRemainingHp).to.equal(50);
|
||||
expect(casts[2].combatResolution.targetRemainingHp).to.equal(25);
|
||||
expect(casts[3].combatResolution.targetRemainingHp).to.equal(0);
|
||||
expect(casts[3].combatResolution.targetDefeated).to.equal(true);
|
||||
});
|
||||
|
||||
test("fifth cast denies target_defeated", function () {
|
||||
const body = res.getBody();
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
expect(body.accepted).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("target_defeated");
|
||||
expect(body.combatResolution).to.equal(undefined);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
meta {
|
||||
name: ability-cast
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-82: happy cast uses beta on slot 0; defeat spine uses slot 1 on alpha (~13s pre-request).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
meta {
|
||||
name: GET combat targets after cast spine (NEO-83)
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
docs {
|
||||
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 {
|
||||
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" } };
|
||||
|
||||
function sleep(ms) {
|
||||
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 } },
|
||||
jsonHeaders,
|
||||
);
|
||||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
slots: [{ slotIndex: 1, 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: 1,
|
||||
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,93 @@
|
|||
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 { 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 } },
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
meta {
|
||||
name: GET combat targets
|
||||
type: http
|
||||
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
|
||||
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,9 @@
|
|||
meta {
|
||||
name: combat-targets
|
||||
}
|
||||
|
||||
docs {
|
||||
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.
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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+ 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
|
||||
|
|
@ -54,6 +54,12 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 a
|
|||
|
||||
**E5M1-05 (NEO-80):** **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** — lazy HP init for **`PrototypeTargetRegistry`** dummies at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80).
|
||||
|
||||
**E5M1-06 (NEO-81):** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — deterministic catalog damage, defeated pre-check deny (`target_defeated`), unknown ability/target validation ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81).
|
||||
|
||||
**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).
|
||||
|
|
@ -88,7 +94,7 @@ The **first shipped four-ability combat spine** under `content/abilities/*.json`
|
|||
|
||||
## Prototype notes (NEO-28)
|
||||
|
||||
Epic 1 **Slice 3** exercises a minimal cast accept/deny surface on **`POST /game/players/{id}/ability-cast`** before the full engine exists: after hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), the server requires **`targetId`** to match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`** for vocabulary alignment. **`accepted: true`** still does not imply damage, cooldown commit, or a full **`CombatResolution`** payload — those remain E5.M1 scope.
|
||||
Epic 1 **Slice 3** cast accept/deny on **`POST /game/players/{id}/ability-cast`** remains the E1.M4 gate funnel: hotbar/loadout validation ([E1.M4](E1_M4_AbilityInputScaffold.md)), **`targetId`** must match the persisted combat lock, resolve in the prototype target registry, and sit within horizontal lock radius against authoritative **`PositionState`**. Deny codes **`invalid_target`** and **`out_of_range`** reuse the same strings as E1.M3 **`TargetState.validity`**. **NEO-82 (E5M1-07):** after gates pass, **`CombatOperations.TryResolve`** runs; accept returns nested wire **`combatResolution`** (`damageDealt`, `targetRemainingHp`, `targetDefeated`, …) and commits per-ability catalog cooldown; **`target_defeated`** JSON deny on re-hit at zero HP.
|
||||
|
||||
## Risks and telemetry
|
||||
|
||||
|
|
|
|||
|
|
@ -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). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-06** [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) 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) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [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), [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).
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -210,8 +210,10 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] **`prototype_pulse`** against full HP dummy deals catalog damage deterministically.
|
||||
- [ ] Second resolve on defeated target returns structured deny (no silent no-op).
|
||||
- [x] **`prototype_pulse`** against full HP dummy deals catalog damage deterministically.
|
||||
- [x] Second resolve on defeated target returns structured deny (no silent no-op).
|
||||
|
||||
**Landed ([NEO-81](https://linear.app/neon-sprawl/issue/NEO-81)):** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — deterministic catalog damage, defeated pre-check deny, unknown ability/target validation ([NEO-81-implementation-plan.md](NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../server/README.md#combat-engine-neo-81).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -234,9 +236,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Accept path returns non-empty resolution payload with deterministic damage.
|
||||
- [ ] Cooldown duration matches ability catalog, not global constant.
|
||||
- [ ] Bruno documents lock → cast → damage → defeat deny spine.
|
||||
- [x] Accept path returns non-empty resolution payload with deterministic damage.
|
||||
- [x] Cooldown duration matches ability catalog, not global constant.
|
||||
- [x] Bruno documents lock → cast → damage → defeat deny spine.
|
||||
|
||||
**Landed ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)):** cast POST invokes **`CombatOperations.TryResolve`**; nested **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** deny ([NEO-82-implementation-plan.md](NEO-82-implementation-plan.md)); Bruno defeat spine.
|
||||
|
||||
**Client counterpart:** [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)
|
||||
|
||||
|
|
@ -258,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)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
# NEO-81 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-81 |
|
||||
| **Title** | E5M1-06: CombatOperations.TryResolve + CombatResult |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-81/e5m1-06-combatoperationstryresolve-combatresult |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-06** |
|
||||
| **Branch** | `NEO-81-combat-operations-try-resolve` |
|
||||
| **Precursor** | [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) — `ICombatEntityHealthStore` (**Done** on `main`); [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) / [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) — static `*Operations.Try*` + `*Result` envelope + `*ReasonCodes`; NEO-80 store handoff for defeated re-hit deny |
|
||||
| **Blocks** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — wire `ability-cast` into combat engine + extend cast response |
|
||||
| **Client counterpart** | None (server-only internal ops); wire **`CombatResolution`** on cast POST is [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82); HP HUD is [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Defeated-target deny scope** | Deny all abilities on defeated target, including zero-damage utility? | **Deny all** — E5M1-07 uses `target_defeated` on cast at defeated dummy regardless of ability kind. | **User:** deny all. |
|
||||
| **Unknown id validation** | Validate ability + target inside `TryResolve` vs assume E1.M4 pre-validated? | **Validate inside** — backlog requires stable deny for unknown ids; mirrors `GatherOperations` / `CraftOperations` self-contained validation. | **User:** validate inside. |
|
||||
| **Zero-damage success path** | `TryGet` only vs `TryApplyDamage(target, 0)`? | **Success with `damageDealt = 0`; `TryGet` snapshot only** — no HP mutation when catalog damage is 0; still lazy-inits via `TryGet`. | **User:** success, TryGet only. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Provide pure server combat resolution: known ability + known prototype target → deterministic catalog damage, remaining HP, and defeated flag. Return structured denies (no silent no-op) when the target is already defeated or ids are unknown.
|
||||
|
||||
**In scope (from Linear + [E5M1-06](E5M1-prototype-backlog.md#e5m1-06--combatoperationstryresolve--combatresult)):**
|
||||
|
||||
- `CombatOperations.TryResolve` using **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`**.
|
||||
- `CombatResult` readonly record struct with **`DamageDealt`**, **`TargetRemainingHp`**, **`TargetDefeated`**, **`Success`**, **`ReasonCode`**.
|
||||
- `CombatReasonCodes` stable deny strings aligned with E1.M4 / E5M1-07 vocabulary.
|
||||
- Unit tests (AAA): happy path (`prototype_pulse` → 25 dmg), zero-damage utility success, defeated-target deny (including guard/dash), unknown ability/target denies.
|
||||
- `server/README.md` combat engine section.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- HTTP wiring / `AbilityCastResponse` extension ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)).
|
||||
- Per-ability cooldown commit ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)).
|
||||
- Gig XP on defeat ([NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)).
|
||||
- Telemetry hooks (reserved for follow-up like NEO-64 / NEO-71 pattern).
|
||||
- Godot / client changes.
|
||||
- Bruno (no HTTP surface in this story).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] **`prototype_pulse`** against full-HP dummy deals catalog damage (25) deterministically.
|
||||
- [x] Second resolve on defeated target returns structured deny (no silent no-op).
|
||||
- [x] Zero-damage utility abilities succeed with **`damageDealt = 0`** on live targets.
|
||||
- [x] Unknown ability / unknown target return stable deny codes from **`CombatOperations`**.
|
||||
- [x] Defeated-target deny applies to zero-damage utility abilities.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`CombatReasonCodes`** — static constants in `Game/Combat/`:
|
||||
- **`TargetDefeated = "target_defeated"`** — target HP is already 0 (checked **before** damage application; no store mutation).
|
||||
- **`UnknownAbility = "unknown_ability"`** — ability id fails **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (matches cast/hotbar vocabulary).
|
||||
- **`UnknownTarget = "unknown_target"`** — target id fails **`ICombatEntityHealthStore.TryGet`** (store gates **`PrototypeTargetRegistry`** ids; matches E1.M3 targeting vocabulary).
|
||||
|
||||
2. **`CombatResult`** — readonly record struct (mirror [`CraftResult`](../../server/NeonSprawl.Server/Game/Crafting/CraftResult.cs)):
|
||||
- **`Success`** — `true` when damage resolution committed (including zero-damage success).
|
||||
- **`ReasonCode`** — `null` on success; stable string on deny.
|
||||
- **`DamageDealt`** — catalog **`AbilityDefRow.BaseDamage`** on success; **0** on deny.
|
||||
- **`TargetRemainingHp`** — authoritative HP after resolve on success; **null** on deny (or current HP on `target_defeated` deny — see step 4).
|
||||
- **`TargetDefeated`** — `true` when post-resolve HP is 0 on success; **false** on deny (even when target was already defeated — caller uses **`ReasonCode`**).
|
||||
|
||||
3. **`CombatOperations`** — static class (mirror [`GatherOperations`](../../server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs)):
|
||||
```csharp
|
||||
public static CombatResult TryResolve(
|
||||
string abilityId,
|
||||
string targetId,
|
||||
IAbilityDefinitionRegistry abilityRegistry,
|
||||
ICombatEntityHealthStore healthStore)
|
||||
```
|
||||
- **Ability gate:** `abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility)` → deny **`unknown_ability`** when false.
|
||||
- **Definition load:** `abilityRegistry.TryGetDefinition(normalizedAbility, out var definition)` — should succeed after normalize; treat miss as **`unknown_ability`** (defensive).
|
||||
- **Target read:** `healthStore.TryGet(targetId, out var snapshot)` → deny **`unknown_target`** when false (lazy-inits live targets).
|
||||
- **Defeated pre-check:** when **`snapshot.Defeated`** → deny **`target_defeated`** **without** calling **`TryApplyDamage`** (NEO-80 handoff; applies to all abilities including zero-damage).
|
||||
- **Zero-damage path:** when **`definition.BaseDamage == 0`** → success with **`DamageDealt = 0`**, **`TargetRemainingHp = snapshot.CurrentHp`**, **`TargetDefeated = snapshot.Defeated`** (always false for live target after pre-check).
|
||||
- **Damage path:** `healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after)` → success with **`DamageDealt = definition.BaseDamage`**, **`TargetRemainingHp = after.CurrentHp`**, **`TargetDefeated = after.Defeated`**.
|
||||
- Private **`Deny(string reasonCode)`** helper returns **`Success = false`**, empty damage fields.
|
||||
|
||||
4. **`target_defeated` deny payload:** On defeated pre-check, set **`TargetRemainingHp = 0`** (from snapshot) so callers/tests can observe current HP without re-querying store; **`DamageDealt = 0`**, **`TargetDefeated = false`** on envelope (deny is **`ReasonCode`**-driven — same pattern as gather depletion deny carrying **`RemainingGathers`**).
|
||||
|
||||
5. **No DI registration** — static ops class; tests construct **`InMemoryCombatEntityHealthStore`** + in-memory registry helper (same as **`AbilityDefinitionRegistryTests`**). Host already registers both dependencies via **`AddAbilityDefinitionCatalog`**.
|
||||
|
||||
6. **`server/README.md`** — new **Combat engine (NEO-81)** section after combat entity health: document **`CombatOperations.TryResolve`**, **`CombatResult`** fields, reason-code table, and cross-link **NEO-82** cast wiring.
|
||||
|
||||
### Expected prototype resolve values
|
||||
|
||||
| Ability | Target | Initial HP | Result |
|
||||
|---------|--------|------------|--------|
|
||||
| `prototype_pulse` | `prototype_target_alpha` | 100 (lazy) | Success: **`damageDealt` 25**, **`targetRemainingHp` 75**, **`targetDefeated` false** |
|
||||
| `prototype_pulse` × 4 | same | — | 4th success: **`targetRemainingHp` 0**, **`targetDefeated` true** |
|
||||
| `prototype_pulse` | same (defeated) | 0 | Deny: **`target_defeated`**, **`damageDealt` 0** |
|
||||
| `prototype_guard` | live alpha | 100 | Success: **`damageDealt` 0**, HP unchanged |
|
||||
| `prototype_guard` | defeated alpha | 0 | Deny: **`target_defeated`** |
|
||||
| `unknown_ability` | alpha | — | Deny: **`unknown_ability`** |
|
||||
| `prototype_pulse` | `not_a_target` | — | Deny: **`unknown_target`** |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatReasonCodes.cs` | Stable deny reason strings for combat ops. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatResult.cs` | Server-internal combat resolution envelope. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatOperations.cs` | Static **`TryResolve`** orchestrating registry + health store. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs` | AAA unit tests for resolve happy/deny paths. |
|
||||
| `docs/plans/NEO-81-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/README.md` | Document **`CombatOperations.TryResolve`**, **`CombatResult`**, reason codes, and NEO-82 handoff. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs` | **Happy path:** `prototype_pulse` on fresh alpha → 25 dmg, 75 HP. **Deterministic chain:** four pulses → defeated on 4th. **Defeated deny:** 5th pulse → **`target_defeated`**, no HP change. **Zero-damage:** `prototype_guard` on live target → success, 0 dmg, HP unchanged. **Defeated + utility:** `prototype_guard` on defeated → **`target_defeated`**. **Unknown ability:** deny **`unknown_ability`**. **Unknown target:** deny **`unknown_target`**. **Burst:** `prototype_burst` → 40 dmg once. Use in-memory registry helper + **`InMemoryCombatEntityHealthStore`**; optional host test resolving registries from **`InMemoryWebApplicationFactory`**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
No Bruno or manual QA for this story (server-only internal ops; no HTTP or client-facing change).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Wire naming: CombatResult vs CombatResolution** | **`CombatResult`** server-internal; NEO-82 promotes to wire **`CombatResolution`** / cast response block (CraftResult → CraftResponse precedent). | **adopted** |
|
||||
| **Defeated deny on zero-damage** | **Deny all abilities** on defeated target (user kickoff). | **adopted** |
|
||||
| **Telemetry hook site** | **Defer** comment-only hooks to NEO-82 or dedicated telemetry story when cast path wires in. | **deferred** |
|
||||
| **Player-as-target / PvP** | **Out of scope** — prototype registry has NPC stubs only; PvP gates are E6.M1. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Defeated pre-check** before damage; no **`TryApplyDamage`** on defeated targets.
|
||||
- **Zero-damage** abilities succeed via **`TryGet`** only (no store mutation).
|
||||
- **Unknown ability/target** validated inside **`TryResolve`** with stable reason codes.
|
||||
- **No HTTP** in NEO-81; cast wiring is [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82).
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`CombatReasonCodes`**, **`CombatResult`**, **`CombatOperations.TryResolve`** added under **`Game/Combat/`**.
|
||||
- Defeated pre-check denies before damage; zero-damage path uses **`TryGet`** only.
|
||||
- **9** AAA tests in **`CombatOperationsTests`** (all green), including host DI resolve.
|
||||
- **`server/README.md`** — combat engine section with reason-code table and NEO-82 handoff.
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
# NEO-82 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-82 |
|
||||
| **Title** | E5M1-07: Wire ability-cast into combat engine + cast response |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-82/e5m1-07-wire-ability-cast-into-combat-engine-cast-response |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-07** |
|
||||
| **Branch** | `NEO-82-wire-ability-cast-combat-resolution` |
|
||||
| **Precursor** | [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) — `CombatOperations.TryResolve` + `CombatResult` (**Done** on `main`); E1.M4 cast funnel (NEO-28/31/32) |
|
||||
| **Pattern** | [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — promote engine envelope to wire JSON; extend existing POST rather than new route |
|
||||
| **Blocks** | [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat, [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client HUD, E5M1-09 telemetry |
|
||||
| **Client counterpart** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) — Godot parses extended cast response + HP HUD (**out of scope** here; no client-facing change in this story) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Resolution wire shape** | Nested block vs flat fields on `AbilityCastResponse`? | **Nested `combatResolution` object** on accept only — E5M1 backlog “optional resolution block”; keeps E1.M4 `accepted`/`reasonCode` envelope stable. | **User:** nested `combatResolution`. |
|
||||
| **Cooldown on `target_defeated`** | Commit catalog cooldown when combat denies re-hit on defeated dummy? | **No** — backlog: per-ability cooldown on successful **resolve** only. | **User:** no cooldown on defeated deny. |
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-82.md`? | **Skip** — server-only; no Godot change; Bruno + README satisfy AC (NEO-70 pattern). | **User:** skip (no client-facing change). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** After E1.M4 cast gates pass, invoke **`CombatOperations.TryResolve`**, return wire **`combatResolution`** on accept, map combat denies (especially **`target_defeated`**) to JSON cast denies, and replace the global prototype cooldown with per-ability **`cooldownSeconds`** from the ability catalog on successful resolve.
|
||||
|
||||
**In scope (from Linear + [E5M1-07](E5M1-prototype-backlog.md#e5m1-07--wire-ability-cast-into-combat-engine--extend-cast-response)):**
|
||||
|
||||
- Wire **`AbilityCastApi`** to **`CombatOperations.TryResolve`** + **`ICombatEntityHealthStore`** after existing NEO-28/32 gates.
|
||||
- Extend **`AbilityCastResponse` v1** with optional nested **`combatResolution`** on accept.
|
||||
- Per-ability cooldown from **`AbilityDefRow.CooldownSeconds`** on successful combat resolve only.
|
||||
- JSON deny **`target_defeated`** when firing at a defeated dummy (combat engine handoff from NEO-81).
|
||||
- Integration tests (AAA) + Bruno: lock target → cast → damage payload; repeated casts until defeat → deny.
|
||||
- **`server/README.md`** cast + cooldown sections updated; cross-link E1.M4 / E5.M1 deny vocabulary.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Godot presentation — **[NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)**.
|
||||
- Gig XP on defeat — **[NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)**.
|
||||
- Telemetry hook emit — **E5M1-09** (comment-only hooks may get placement notes; no E9.M1 ingest).
|
||||
- **`GET /game/world/combat-targets`** — **E5M1-08**.
|
||||
- Rich **`ThreatState`**, player damage, PvP eligibility.
|
||||
- **`docs/manual-qa/NEO-82.md`** (kickoff decision).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Accept path returns non-empty **`combatResolution`** payload with deterministic catalog damage.
|
||||
- [x] Cooldown duration matches ability catalog (**`cooldownSeconds`**), not **`AbilityPrototypeCooldown.GlobalDuration`**.
|
||||
- [x] Bruno documents lock → cast → damage → defeat deny spine.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Handler flow (`AbilityCastApi`)** — after existing loadout / target / range gates and **`on_cooldown`** pre-check:
|
||||
- Inject **`ICombatEntityHealthStore`** alongside **`IAbilityDefinitionRegistry`**.
|
||||
- Call **`CombatOperations.TryResolve(normalizedRequest, lookupKey, abilities, healthStore)`**.
|
||||
- **Combat deny** (`!result.Success`): return **`accepted: false`**, **`reasonCode`** = **`result.ReasonCode`** (primarily **`target_defeated`**; defensive **`unknown_target`** / **`unknown_ability`** should not occur post-gates). **Do not** call **`StartCooldown`** (kickoff decision).
|
||||
- **Combat success**: load definition via **`TryGetDefinition`**, **`StartCooldown(id, slotIndex, now, TimeSpan.FromSeconds(definition.CooldownSeconds))`**, return **`accepted: true`** with **`combatResolution`** mapped from **`CombatResult`**.
|
||||
|
||||
2. **Wire DTOs (`AbilityCastDtos.cs`)** — add **`CombatResolutionJson`**:
|
||||
- **`abilityId`** — normalized ability id (same as bound/request).
|
||||
- **`targetId`** — normalized target id (lowercase registry key).
|
||||
- **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`** — promoted from **`CombatResult`** (non-null HP on success).
|
||||
- On **`AbilityCastResponse`**: optional **`combatResolution`** — **`JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)`**; present only when **`accepted: true`**.
|
||||
|
||||
3. **Mapping helper** — internal **`MapCombatResolution`** / **`MapAcceptResponse`** (mirror **`PlayerCraftApi.MapResponse`**) to keep route handler thin.
|
||||
|
||||
4. **Cooldown migration** — remove **`AbilityPrototypeCooldown.GlobalDuration`** usage from accept path. Keep class with updated remark (“superseded by catalog per E5M1-07”) or delete constant if unused after test updates. Snapshot GET unchanged (still reads store ends); duration source is now catalog on commit.
|
||||
|
||||
5. **Deny vocabulary alignment** — add **`target_defeated`** to README cast deny table; cross-link **`CombatReasonCodes`**. E1 gate denies (**`invalid_target`**, etc.) unchanged and evaluated **before** combat.
|
||||
|
||||
6. **Bruno (`bruno/neon-sprawl-server/ability-cast/`)**:
|
||||
- Update **`Post cast happy.bru`** — assert **`combatResolution.damageDealt === 25`**, **`targetRemainingHp === 75`**, **`targetDefeated === false`** for first pulse on alpha.
|
||||
- Add **`Post cast lock pulse defeat spine.bru`** — pre-request: move + target select alpha; seq of **4** pulse casts asserting HP stepping (75 → 50 → 25 → 0, **`targetDefeated`** true on 4th); **5th** cast → **`accepted: false`**, **`reasonCode: target_defeated`**.
|
||||
- Update **`folder.bru`** docs with run order / NEO-82 spine note.
|
||||
|
||||
7. **`server/README.md`** — rewrite Ability cast section: combat resolution block field table, **`target_defeated`** deny, per-ability cooldown from catalog; update Cooldown snapshot section to reference catalog durations.
|
||||
|
||||
8. **Module / backlog docs** — during implementation reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-07 checkboxes and [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md) prototype handoff paragraph (cast now returns real resolution).
|
||||
|
||||
### Expected cast outcomes (prototype_pulse → alpha, fresh HP)
|
||||
|
||||
| Cast # | `accepted` | `reasonCode` | `combatResolution.damageDealt` | `targetRemainingHp` | `targetDefeated` | Cooldown committed |
|
||||
|--------|------------|--------------|-------------------------------|---------------------|------------------|-------------------|
|
||||
| 1 | true | null | 25 | 75 | false | 3.0s (pulse catalog) |
|
||||
| 2–3 | true | null | 25 | 50 / 25 | false | yes |
|
||||
| 4 | true | null | 25 | 0 | true | yes |
|
||||
| 5 | false | `target_defeated` | (absent) | — | — | **no** |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru` | Bruno lock → cast → damage → defeat → deny spine. |
|
||||
| `docs/plans/NEO-82-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Invoke **`CombatOperations.TryResolve`**, map combat deny/success, per-ability cooldown commit. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastDtos.cs` | Add **`CombatResolutionJson`**; extend **`AbilityCastResponse`**. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityPrototypeCooldown.cs` | Mark global duration superseded; stop using on accept path. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Assert resolution payload, defeat deny, per-ability cooldown (e.g. guard 6s vs pulse 3s). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs` | Advance fake clock by catalog duration instead of global constant where applicable. |
|
||||
| `bruno/neon-sprawl-server/ability-cast/Post cast happy.bru` | Assert **`combatResolution`** fields on accept. |
|
||||
| `bruno/neon-sprawl-server/ability-cast/folder.bru` | Document NEO-82 spine run order. |
|
||||
| `server/README.md` | Cast response **`combatResolution`** table, **`target_defeated`**, catalog cooldown policy. |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | Mark E5M1-07 landed when complete. |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Update cast handoff — resolution now on wire. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | **Happy path:** accept + **`combatResolution`** (25 dmg, 75 HP). **Defeat chain:** four pulses → 4th **`targetDefeated` true**; 5th → **`target_defeated` deny**, no extra HP mutation. **Zero-damage:** bind **`prototype_guard`**, accept with **`damageDealt` 0**, HP unchanged. **Per-ability cooldown:** guard 6s — still **`on_cooldown`** at 3.5s after first accept; pulse 3s — accept again after 3.5s. **Existing denies** unchanged (invalid_target, on_cooldown, etc.). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs` | Snapshot end time reflects catalog cooldown after successful combat resolve (update clock advance constant). |
|
||||
|
||||
Bruno spine covers manual verification; no **`docs/manual-qa/NEO-82.md`** (server-only, no Godot).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Nested vs flat resolution fields** | **Nested `combatResolution`** (user kickoff). | **adopted** |
|
||||
| **Cooldown on defeated deny** | **No commit** (user kickoff). | **adopted** |
|
||||
| **Combat `unknown_target` after E1 gates** | **Map to deny as-is** if ever returned — defensive; normal path uses **`invalid_target`** at gate layer. | **adopted** |
|
||||
| **Telemetry hook placement** | **Defer** catalog emit to E5M1-09; optional **`TODO(E9.M1)`** near combat success path only. | **deferred** |
|
||||
| **Gig XP on 4th pulse defeat** | **Out of scope** — NEO-44 hooks defeat transition separately. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **`combatResolution`** nested object on accept only; omitted on all denies.
|
||||
- Per-ability **`cooldownSeconds`** commit only on successful **`CombatOperations`** resolve.
|
||||
- **`target_defeated`** JSON deny on defeated re-hit; no cooldown.
|
||||
- No manual QA doc; Bruno + README sufficient (no client-facing change — NEO-85 owns HUD).
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`CombatResolutionJson`** + extended **`AbilityCastResponse`** in **`AbilityCastDtos.cs`**.
|
||||
- **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`**; per-ability catalog cooldown on success; **`target_defeated`** deny without cooldown.
|
||||
- **4** new AAA tests in **`AbilityCastApiTests`** (resolution, zero-damage, defeat chain, catalog cooldown); existing tests updated for resolution payload.
|
||||
- Bruno happy + defeat spine; **`server/README.md`**, E5M1 backlog, E5_M1 module doc updated.
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# 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
|
||||
|
||||
- [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
|
||||
|
||||
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 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.
|
||||
|
||||
### 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 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. |
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 + 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.
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# Code review — NEO-81 CombatOperations.TryResolve + CombatResult
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-81-combat-operations-try-resolve` · commits `849c34f`–`ca15e7a` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
**Follow-up:** Code-review suggestions and nits addressed in a follow-up commit (doc sync, test AAA/assertions, dash smoke).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-81 delivers **E5M1-06**: a static `CombatOperations.TryResolve` orchestrating `IAbilityDefinitionRegistry` and `ICombatEntityHealthStore` into a server-internal `CombatResult` envelope with stable deny codes (`unknown_ability`, `unknown_target`, `target_defeated`). Implementation matches kickoff decisions: self-contained id validation, defeated pre-check before any damage (including zero-damage utilities), zero-damage success via `TryGet` only, and `target_defeated` deny carrying `targetRemainingHp = 0` with `targetDefeated = false` on the envelope. Nine AAA unit/host tests cover pulse/guard/burst paths, four-pulse defeat chain, defeated re-hit deny, unknown ids, and DI resolution. `server/README.md` documents the combat engine section and NEO-82 handoff. Residual risk is low — server-only ops with no HTTP surface; module tracking docs and minor test/doc backlog sync should close on merge (same pattern as NEO-80 follow-ups).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-81-implementation-plan.md`](../plans/NEO-81-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation, and expected resolve table align with code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-06** | ~~**Partially matches** — story AC checkboxes still unchecked; no landed note yet (E5M1-05 pattern).~~ **Matches** — AC checked, landed note added. **Done.** |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | ~~**Partially matches** — Status still reads “E5M1-06+ pending”; no related implementation slice for NEO-81.~~ **Matches** — Status + E5M1-06 slice entry updated. **Done.** |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | ~~**Partially matches** — E5.M1 note stops at NEO-80; NEO-81 `CombatOperations` not cited.~~ **Matches** — NEO-81 landed note added. **Done.** |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | ~~**Partially matches** — E5.M1 row should add NEO-81 landed after merge.~~ **Matches** — NEO-81 row + links updated. **Done.** |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only internal ops; no client or HTTP changes. |
|
||||
| [`docs/decomposition/modules/pvp_combat_integration.md`](../decomposition/modules/pvp_combat_integration.md) | **N/A** — prototype NPC targets only; PvP gates out of scope per plan. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — combat engine section, reason-code table, `CombatResult` field semantics, NEO-82 cross-link. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; cast wire is NEO-82, HUD is NEO-85. |
|
||||
| Manual QA | **N/A** — plan skip rationale holds (server-only internal ops). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** to cite **E5M1-06 NEO-81 landed** (`CombatOperations.TryResolve` + `CombatResult`), and add a **Related implementation slices** entry linking the NEO-81 plan and README combat engine section (mirror NEO-80 landed pattern).~~ **Done.**
|
||||
|
||||
2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-81 landed:** `CombatOperations`, `CombatResult`, `CombatReasonCodes`, defeated pre-check deny vocabulary.~~ **Done.**
|
||||
|
||||
3. ~~**E5M1 backlog landed note** — In `docs/plans/E5M1-prototype-backlog.md` § E5M1-06, check AC boxes and add a one-line landed note with link to [NEO-81 implementation plan](../plans/NEO-81-implementation-plan.md) (same pattern as E5M1-05).~~ **Done.**
|
||||
|
||||
4. ~~**Defeated deny assertion parity** — `TryResolve_WithGuardOnDefeatedTarget_ShouldDenyWithTargetDefeated` omits `TargetRemainingHp` / `TargetDefeated` envelope assertions that the pulse defeated test covers; add `Assert.Equal(0, result.TargetRemainingHp)` and `Assert.False(result.TargetDefeated)` for consistency with the plan’s deny payload contract.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: `TryResolve_WithFourPulses_ShouldDefeatTargetOnFourthResolve` runs four resolves in **Act**; pulses 1–3 are setup for the fourth outcome — move them to **Arrange** for stricter AAA (same note as NEO-80 four-pulse pattern).~~ **Done.**
|
||||
|
||||
- ~~Nit: `CreatePrototypeRegistry` includes **`prototype_dash`** but no dash resolve test; guard covers the zero-damage path — optional dash smoke for catalog completeness.~~ **Done** — added `TryResolve_WithDashOnLiveTarget_ShouldSucceedWithZeroDamage`.
|
||||
|
||||
- Nit: `TryApplyDamage` false branch maps to **`unknown_target`** — correct for current store contract (unknown/empty/negative damage), but if store semantics expand (e.g. internal failure), NEO-82 cast wiring may want a distinct ops-level code; defer until cast integration. **Deferred** — no change in NEO-81.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~CombatOperationsTests"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
**CombatOperationsTests:** 10/10 passed (local run after follow-up).
|
||||
|
||||
**Full suite:** 389 passed, 1 failed — `TargetingApiTests.GetTarget_ShouldReturnNone_WhenNoLock` (unrelated to this branch; investigate separately if CI is green on `main`).
|
||||
|
||||
Manual: **N/A** — no HTTP or client surface in this story.
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Code review — NEO-82 Wire ability-cast into combat engine + cast response
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-82-wire-ability-cast-combat-resolution` · commits `4126ac9`–`e8be100` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
**Follow-up:** Code review suggestions and nits addressed in follow-up commit on `NEO-82-wire-ability-cast-combat-resolution`. Bugbot + CI fixes in `52bf9fc` (definition-before-resolve; Bruno beta/alpha isolation).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-82 delivers **E5M1-07**: after E1.M4 cast gates pass, `AbilityCastApi` invokes `CombatOperations.TryResolve`, returns nested wire `combatResolution` on accept, maps combat denies (notably `target_defeated`) to JSON cast denies without cooldown commit, and replaces the global prototype cooldown with per-ability catalog `cooldownSeconds` on successful resolve. Handler flow, DTO shape, kickoff decisions (nested block, no cooldown on defeated deny), Bruno happy + defeat spine, and README updates align with the implementation plan. Four new integration tests cover zero-damage accept, four-pulse defeat chain, and guard-vs-pulse catalog cooldown; existing cast tests assert resolution fields. Residual risk is low — server-only HTTP extension with clear deny vocabulary; module tracking docs should sync on merge (same follow-up pattern as NEO-81).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-82-implementation-plan.md`](../plans/NEO-82-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, expected cast table, and reconciliation align with code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-07** | **Matches** — AC checked, landed note added. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites E5M1-07 NEO-82 landed; **Related implementation slices** entry added. ~~Partially matches~~ **Done.** |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-82 landed. ~~Partially matches~~ **Done.** |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row cites NEO-82 cast → `combatResolution`. ~~Partially matches~~ **Done.** |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server validates and computes all combat outcomes; client unchanged (NEO-85). |
|
||||
| [`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** — cast `combatResolution` table, `target_defeated` deny, catalog cooldown policy, combat engine cross-link. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents NEO-85 client counterpart; no Godot change in scope. |
|
||||
| Manual QA | **N/A** — kickoff skip; Bruno + README sufficient. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Module doc status + slice entry** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** to cite **E5M1-07 NEO-82 landed** (cast POST returns `combatResolution`), and add **E5M1-07 (NEO-82)** under **Related implementation slices** linking the plan and README ability-cast section (mirror NEO-81 landed pattern).~~ **Done.**
|
||||
|
||||
2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-82 landed:** `AbilityCastApi` → `CombatOperations.TryResolve`, nested `combatResolution`, per-ability catalog cooldown, `target_defeated` cast deny.~~ **Done.**
|
||||
|
||||
3. ~~**Bruno defeat spine HP stepping** — Plan §6 calls for asserting HP 75 → 50 → 25 → 0 on casts 1–4; `Post cast lock pulse defeat spine.bru` only captures/asserts the fourth cast. Consider storing intermediate cast bodies in pre-request and asserting `targetRemainingHp` on each pulse for stronger manual lock-in (non-blocking — fifth deny + integration test cover the critical path).~~ **Done.** — `neo82CastResults` stores all four casts; tests assert HP stepping.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: `PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated` runs pulses 1–3 inside **Act**; move them to **Arrange** for stricter AAA (same note as NEO-81 four-pulse pattern).~~ **Done.** — Pulses 1–3 in Arrange; Act is 4th + 5th cast; Assert includes HP stepping.
|
||||
|
||||
- ~~Nit: After successful `TryResolve`, `TryGetDefinition` runs again solely for `CooldownSeconds` — `CombatOperations` already loaded the definition internally. Acceptable for thin handler clarity; optional future refactor if catalog lookups become hot-path. **Deferred** — no hot-path concern in prototype.~~ **Done.** — `TryGetDefinition` moved before `TryResolve` so cooldown lookup cannot deny after HP mutation (Bugbot fix).
|
||||
|
||||
- Nit: `AbilityPrototypeCooldown.GlobalDuration` is `[Obsolete]` but retained — matches plan; no remaining runtime references in tests or accept path. **No action** — intentional per plan.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~AbilityCastApiTests|FullyQualifiedName~CooldownSnapshotApiTests"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
**AbilityCast + CooldownSnapshot tests:** 22/22 passed (local run).
|
||||
|
||||
**Full suite:** 394/394 passed (local run).
|
||||
|
||||
**Bruno (manual):** Run `bruno/neon-sprawl-server/ability-cast/` folder — happy cast + defeat spine (seq 23, ~10s pre-request cooldown waits).
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# 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`
|
||||
|
||||
**Follow-up:** Code review suggestions and nits addressed in follow-up commit on `NEO-83-combat-targets-snapshot`.
|
||||
|
||||
## 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.~~ **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.~~ **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. **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).~~ **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. **No action** — per review.
|
||||
|
||||
## 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 one-pulse cast→GET (~0s); seq 3 four-pulse defeat spine (~13s pre-request + HP stepping assertions).
|
||||
|
|
@ -2,6 +2,7 @@ using System.Net;
|
|||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using Xunit;
|
||||
|
|
@ -44,6 +45,27 @@ public sealed class AbilityCastApiTests
|
|||
Assert.True(body!.SelectionApplied);
|
||||
}
|
||||
|
||||
private static async Task BindSlot0GuardAsync(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.PrototypeGuard }],
|
||||
});
|
||||
post.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static AbilityCastRequest PulseCastRequest() =>
|
||||
new()
|
||||
{
|
||||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
};
|
||||
|
||||
private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
|
|
@ -82,6 +104,12 @@ public sealed class AbilityCastApiTests
|
|||
Assert.NotNull(body);
|
||||
Assert.True(body!.Accepted);
|
||||
Assert.Null(body.ReasonCode);
|
||||
Assert.NotNull(body.CombatResolution);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, body.CombatResolution!.AbilityId);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.CombatResolution.TargetId);
|
||||
Assert.Equal(25, body.CombatResolution.DamageDealt);
|
||||
Assert.Equal(75, body.CombatResolution.TargetRemainingHp);
|
||||
Assert.False(body.CombatResolution.TargetDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -416,6 +444,32 @@ public sealed class AbilityCastApiTests
|
|||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAbilityCast_ShouldStartCatalogCooldown_WhenResolveSucceedsAfterDefinitionGate()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
await BindSlot0PulseAsync(client);
|
||||
await LockPrototypeTargetAlphaAsync(client);
|
||||
var cast = PulseCastRequest();
|
||||
|
||||
// Act
|
||||
var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
var snapshotResponse = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot");
|
||||
|
||||
// Assert
|
||||
var body = await castResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.Equal(HttpStatusCode.OK, castResponse.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Accepted);
|
||||
Assert.NotNull(body.CombatResolution);
|
||||
var snapshot = await snapshotResponse.Content.ReadFromJsonAsync<CooldownSnapshotResponse>();
|
||||
Assert.NotNull(snapshot);
|
||||
var slot0 = snapshot!.Slots.Single(s => s.SlotIndex == 0);
|
||||
Assert.NotNull(slot0.CooldownEndsAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAbilityCast_ShouldDenyOnCooldown_WhenRepeatedWhileCooling()
|
||||
{
|
||||
|
|
@ -467,7 +521,7 @@ public sealed class AbilityCastApiTests
|
|||
|
||||
// Act
|
||||
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50));
|
||||
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
||||
var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
|
||||
// Assert
|
||||
|
|
@ -479,5 +533,130 @@ public sealed class AbilityCastApiTests
|
|||
Assert.NotNull(body2);
|
||||
Assert.True(body1!.Accepted);
|
||||
Assert.True(body2!.Accepted);
|
||||
Assert.NotNull(body2.CombatResolution);
|
||||
Assert.Equal(50, body2.CombatResolution!.TargetRemainingHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAbilityCast_ShouldReturnCombatResolution_WhenZeroDamageUtilityAccepted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
await BindSlot0GuardAsync(client);
|
||||
await LockPrototypeTargetAlphaAsync(client);
|
||||
var cast = new AbilityCastRequest
|
||||
{
|
||||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
|
||||
// Assert
|
||||
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Accepted);
|
||||
Assert.NotNull(body.CombatResolution);
|
||||
Assert.Equal(0, body.CombatResolution!.DamageDealt);
|
||||
Assert.Equal(100, body.CombatResolution.TargetRemainingHp);
|
||||
Assert.False(body.CombatResolution.TargetDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAbilityCast_ShouldDefeatTargetAfterFourPulses_ThenDenyTargetDefeated()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
Assert.NotNull(factory.FakeClock);
|
||||
await BindSlot0PulseAsync(client);
|
||||
await LockPrototypeTargetAlphaAsync(client);
|
||||
var cast = PulseCastRequest();
|
||||
AbilityCastResponse? first = null;
|
||||
AbilityCastResponse? second = null;
|
||||
AbilityCastResponse? third = null;
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
if (i == 0)
|
||||
{
|
||||
first = body;
|
||||
}
|
||||
else if (i == 1)
|
||||
{
|
||||
second = body;
|
||||
}
|
||||
else
|
||||
{
|
||||
third = body;
|
||||
}
|
||||
|
||||
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
// Act
|
||||
var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
||||
var fifthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(75, first!.CombatResolution!.TargetRemainingHp);
|
||||
Assert.NotNull(second);
|
||||
Assert.Equal(50, second!.CombatResolution!.TargetRemainingHp);
|
||||
Assert.NotNull(third);
|
||||
Assert.Equal(25, third!.CombatResolution!.TargetRemainingHp);
|
||||
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.NotNull(fourth);
|
||||
Assert.True(fourth!.Accepted);
|
||||
Assert.NotNull(fourth.CombatResolution);
|
||||
Assert.Equal(0, fourth.CombatResolution!.TargetRemainingHp);
|
||||
Assert.True(fourth.CombatResolution.TargetDefeated);
|
||||
var fifth = await fifthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.NotNull(fifth);
|
||||
Assert.False(fifth!.Accepted);
|
||||
Assert.Equal(CombatReasonCodes.TargetDefeated, fifth.ReasonCode);
|
||||
Assert.Null(fifth.CombatResolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAbilityCast_ShouldUseCatalogCooldown_WhenGuardCooldownLongerThanPulse()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
Assert.NotNull(factory.FakeClock);
|
||||
await BindSlot0GuardAsync(client);
|
||||
await LockPrototypeTargetAlphaAsync(client);
|
||||
var cast = new AbilityCastRequest
|
||||
{
|
||||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
};
|
||||
|
||||
// Act
|
||||
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
factory.FakeClock.Advance(TimeSpan.FromSeconds(3.5));
|
||||
var second = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
factory.FakeClock.Advance(TimeSpan.FromSeconds(3));
|
||||
var third = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
|
||||
// Assert
|
||||
var body1 = await first.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
var body2 = await second.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
var body3 = await third.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.True(body1!.Accepted);
|
||||
Assert.False(body2!.Accepted);
|
||||
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
|
||||
Assert.True(body3!.Accepted);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ public sealed class CooldownSnapshotApiTests
|
|||
// Act
|
||||
var castResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
var snapDuring = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot");
|
||||
factory.FakeClock!.Advance(AbilityPrototypeCooldown.GlobalDuration + TimeSpan.FromMilliseconds(50));
|
||||
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
||||
var snapAfter = await client.GetAsync("/game/players/dev-local-1/cooldown-snapshot");
|
||||
|
||||
// Assert
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
||||
public sealed class CombatOperationsTests
|
||||
{
|
||||
private static AbilityDefinitionRegistry CreatePrototypeRegistry()
|
||||
{
|
||||
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypeBurst,
|
||||
"Prototype Burst",
|
||||
40,
|
||||
5.0,
|
||||
"attack"),
|
||||
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypeDash,
|
||||
"Prototype Dash",
|
||||
0,
|
||||
4.0,
|
||||
"movement"),
|
||||
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypeGuard,
|
||||
"Prototype Guard",
|
||||
0,
|
||||
6.0,
|
||||
"utility"),
|
||||
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"Prototype Pulse",
|
||||
25,
|
||||
3.0,
|
||||
"attack"),
|
||||
};
|
||||
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1);
|
||||
return new AbilityDefinitionRegistry(catalog);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithPulseOnFreshAlpha_ShouldDealCatalogDamage()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Equal(25, result.DamageDealt);
|
||||
Assert.Equal(75, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithFourPulses_ShouldDefeatTargetOnFourthResolve()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
_ = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0, result.TargetRemainingHp);
|
||||
Assert.True(result.TargetDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_OnDefeatedTarget_ShouldDenyWithTargetDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
_ = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(CombatReasonCodes.TargetDefeated, result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Equal(0, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
Assert.Equal(0, snapshot.CurrentHp);
|
||||
Assert.True(snapshot.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithGuardOnLiveTarget_ShouldSucceedWithZeroDamage()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeGuard,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithGuardOnDefeatedTarget_ShouldDenyWithTargetDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeGuard,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(CombatReasonCodes.TargetDefeated, result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Equal(0, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithDashOnLiveTarget_ShouldSucceedWithZeroDamage()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeDash,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithUnknownAbility_ShouldDenyWithUnknownAbility()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
"not_a_real_ability",
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(CombatReasonCodes.UnknownAbility, result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Null(result.TargetRemainingHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithUnknownTarget_ShouldDenyWithUnknownTarget()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
"not_a_target",
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(CombatReasonCodes.UnknownTarget, result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Null(result.TargetRemainingHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolve_WithBurstOnFreshAlpha_ShouldDealFortyDamage()
|
||||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeBurst,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(40, result.DamageDealt);
|
||||
Assert.Equal(60, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveCombatDependenciesFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
var registry = factory.Services.GetRequiredService<IAbilityDefinitionRegistry>();
|
||||
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(25, result.DamageDealt);
|
||||
Assert.Equal(75, result.TargetRemainingHp);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ICombatEntityHealthStore>();
|
||||
_ = 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<CombatTargetFixtureResponse>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
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();
|
||||
var castBody = await castResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/world/combat-targets");
|
||||
var body = await response.Content.ReadFromJsonAsync<CombatTargetsListResponse>();
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ namespace NeonSprawl.Server.Game.AbilityInput;
|
|||
/// TODO(E9.M1): emit cataloged events (payload: route player id, slotIndex, abilityId, targetId; on deny include reasonCode).
|
||||
/// Optional dev stdout: set <c>NEON_SPRAWL_API_LOG=1</c> (see <see cref="Diagnostics.PrototypeApiConsoleLog"/>) for one line per JSON cast response.
|
||||
/// HTTP 400/404 here are outside the v1 cast deny contract (no JSON <c>AbilityCastResponse</c>).
|
||||
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the prototype global cooldown window.
|
||||
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window.
|
||||
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
|
||||
/// </remarks>
|
||||
public static class AbilityCastApi
|
||||
{
|
||||
|
|
@ -59,6 +60,7 @@ public static class AbilityCastApi
|
|||
IPlayerTargetLockStore locks,
|
||||
IPlayerAbilityCooldownStore cooldowns,
|
||||
IAbilityDefinitionRegistry abilities,
|
||||
ICombatEntityHealthStore healthStore,
|
||||
TimeProvider clock) =>
|
||||
{
|
||||
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
|
||||
|
|
@ -182,17 +184,74 @@ public static class AbilityCastApi
|
|||
new AbilityCastResponse { Accepted = false, ReasonCode = ReasonOnCooldown });
|
||||
}
|
||||
|
||||
cooldowns.StartCooldown(id, body.SlotIndex, now, AbilityPrototypeCooldown.GlobalDuration);
|
||||
if (!abilities.TryGetDefinition(normalizedRequest, out var definition))
|
||||
{
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse
|
||||
{
|
||||
Accepted = false,
|
||||
ReasonCode = ReasonUnknownAbility,
|
||||
},
|
||||
normalizedRequest);
|
||||
}
|
||||
|
||||
var combatResult = CombatOperations.TryResolve(
|
||||
normalizedRequest,
|
||||
lookupKey,
|
||||
abilities,
|
||||
healthStore);
|
||||
|
||||
if (!combatResult.Success)
|
||||
{
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse
|
||||
{
|
||||
Accepted = false,
|
||||
ReasonCode = combatResult.ReasonCode,
|
||||
},
|
||||
normalizedRequest);
|
||||
}
|
||||
|
||||
cooldowns.StartCooldown(
|
||||
id,
|
||||
body.SlotIndex,
|
||||
now,
|
||||
TimeSpan.FromSeconds(definition.CooldownSeconds));
|
||||
|
||||
// NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit).
|
||||
// Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
|
||||
return JsonAbilityCast(
|
||||
id,
|
||||
body,
|
||||
new AbilityCastResponse { Accepted = true },
|
||||
MapAcceptResponse(normalizedRequest, lookupKey, combatResult),
|
||||
normalizedRequest);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static AbilityCastResponse MapAcceptResponse(
|
||||
string normalizedAbilityId,
|
||||
string normalizedTargetId,
|
||||
CombatResult result)
|
||||
{
|
||||
return new AbilityCastResponse
|
||||
{
|
||||
Accepted = true,
|
||||
CombatResolution = new CombatResolutionJson
|
||||
{
|
||||
AbilityId = normalizedAbilityId,
|
||||
TargetId = normalizedTargetId,
|
||||
DamageDealt = result.DamageDealt,
|
||||
TargetRemainingHp = result.TargetRemainingHp!.Value,
|
||||
TargetDefeated = result.TargetDefeated,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,25 @@ public sealed class AbilityCastRequest
|
|||
public string? TargetId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Combat outcome block on successful cast accept (NEO-82).</summary>
|
||||
public sealed class CombatResolutionJson
|
||||
{
|
||||
[JsonPropertyName("abilityId")]
|
||||
public required string AbilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("targetId")]
|
||||
public required string TargetId { get; init; }
|
||||
|
||||
[JsonPropertyName("damageDealt")]
|
||||
public int DamageDealt { get; init; }
|
||||
|
||||
[JsonPropertyName("targetRemainingHp")]
|
||||
public int TargetRemainingHp { get; init; }
|
||||
|
||||
[JsonPropertyName("targetDefeated")]
|
||||
public bool TargetDefeated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST response for cast submit (prototype accept/deny; NEO-31 + NEO-28 target rules).</summary>
|
||||
public sealed class AbilityCastResponse
|
||||
{
|
||||
|
|
@ -38,4 +57,9 @@ public sealed class AbilityCastResponse
|
|||
[JsonPropertyName("reasonCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasonCode { get; init; }
|
||||
|
||||
/// <summary>Present only when <see cref="Accepted"/> is true after combat resolve (NEO-82).</summary>
|
||||
[JsonPropertyName("combatResolution")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public CombatResolutionJson? CombatResolution { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.AbilityInput;
|
||||
|
||||
/// <summary>Prototype global cooldown applied on successful cast (NEO-32) until E5.M1 ability catalog.</summary>
|
||||
/// <summary>Legacy prototype global cooldown constant (NEO-32); superseded by per-ability catalog durations (NEO-82).</summary>
|
||||
public static class AbilityPrototypeCooldown
|
||||
{
|
||||
/// <summary>Single duration for every known prototype ability on accept.</summary>
|
||||
/// <summary>Former global duration; kept for historical test/doc references — cast uses catalog <c>cooldownSeconds</c>.</summary>
|
||||
[Obsolete("Use ability catalog cooldownSeconds on successful combat resolve (NEO-82).")]
|
||||
public static readonly TimeSpan GlobalDuration = TimeSpan.FromSeconds(3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates deterministic combat resolution: ability catalog damage + prototype target HP (NEO-81).
|
||||
/// Cast HTTP wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-82).
|
||||
/// </summary>
|
||||
public static class CombatOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves one ability against one prototype combat target using catalog damage and the health store.
|
||||
/// Defeated targets deny before damage application; zero-damage abilities succeed without mutating HP.
|
||||
/// </summary>
|
||||
public static CombatResult TryResolve(
|
||||
string abilityId,
|
||||
string targetId,
|
||||
IAbilityDefinitionRegistry abilityRegistry,
|
||||
ICombatEntityHealthStore healthStore)
|
||||
{
|
||||
if (!abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility) ||
|
||||
!abilityRegistry.TryGetDefinition(normalizedAbility, out var definition))
|
||||
{
|
||||
return Deny(CombatReasonCodes.UnknownAbility);
|
||||
}
|
||||
|
||||
if (!healthStore.TryGet(targetId, out var snapshot))
|
||||
{
|
||||
return Deny(CombatReasonCodes.UnknownTarget);
|
||||
}
|
||||
|
||||
if (snapshot.Defeated)
|
||||
{
|
||||
return Deny(CombatReasonCodes.TargetDefeated, snapshot.CurrentHp);
|
||||
}
|
||||
|
||||
if (definition.BaseDamage == 0)
|
||||
{
|
||||
return new CombatResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
DamageDealt: 0,
|
||||
TargetRemainingHp: snapshot.CurrentHp,
|
||||
TargetDefeated: snapshot.Defeated);
|
||||
}
|
||||
|
||||
if (!healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after))
|
||||
{
|
||||
return Deny(CombatReasonCodes.UnknownTarget);
|
||||
}
|
||||
|
||||
return new CombatResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
DamageDealt: definition.BaseDamage,
|
||||
TargetRemainingHp: after.CurrentHp,
|
||||
TargetDefeated: after.Defeated);
|
||||
}
|
||||
|
||||
private static CombatResult Deny(string reasonCode, int? targetRemainingHp = null) =>
|
||||
new(
|
||||
Success: false,
|
||||
ReasonCode: reasonCode,
|
||||
DamageDealt: 0,
|
||||
TargetRemainingHp: targetRemainingHp,
|
||||
TargetDefeated: false);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Stable deny reason codes for combat operations (NEO-81).</summary>
|
||||
public static class CombatReasonCodes
|
||||
{
|
||||
/// <summary>Target HP is already zero before resolve; no damage applied.</summary>
|
||||
public const string TargetDefeated = "target_defeated";
|
||||
|
||||
/// <summary>Passthrough from <see cref="AbilityCastApi.ReasonUnknownAbility"/>.</summary>
|
||||
public const string UnknownAbility = AbilityCastApi.ReasonUnknownAbility;
|
||||
|
||||
/// <summary>Passthrough from <see cref="TargetingApi.ReasonUnknownTarget"/>.</summary>
|
||||
public const string UnknownTarget = TargetingApi.ReasonUnknownTarget;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Server-internal combat resolution envelope (NEO-81); promoted to wire JSON via cast response extension (NEO-82).
|
||||
/// </summary>
|
||||
/// <param name="DamageDealt">Catalog damage applied on success; zero on deny.</param>
|
||||
/// <param name="TargetRemainingHp">Authoritative HP after success; optional on deny (e.g. zero on <c>target_defeated</c>).</param>
|
||||
/// <param name="TargetDefeated"><c>true</c> when post-resolve HP is zero on success; <c>false</c> on deny.</param>
|
||||
public readonly record struct CombatResult(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
int DamageDealt,
|
||||
int? TargetRemainingHp,
|
||||
bool TargetDefeated);
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Dev-only route to reset prototype combat dummy HP for Bruno and manual QA (NEO-83).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>JSON body for <c>POST /game/__dev/combat-targets-fixture</c> (NEO-83 Bruno/QA).</summary>
|
||||
public sealed class CombatTargetFixtureRequest
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>JSON response when the dev combat-target fixture applies.</summary>
|
||||
public sealed class CombatTargetFixtureResponse
|
||||
{
|
||||
[JsonPropertyName("applied")]
|
||||
public required bool Applied { get; init; }
|
||||
}
|
||||
|
|
@ -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,44 @@
|
|||
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))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Prototype combat target '{id}' is registered but missing from {nameof(ICombatEntityHealthStore)}.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ public sealed class GamePositionOptions
|
|||
/// <summary>When true, maps <c>POST …/__dev/mastery-fixture</c> for Bruno/manual QA state setup (also enabled in Development).</summary>
|
||||
public bool EnableMasteryFixtureApi { get; set; }
|
||||
|
||||
/// <summary>When true, maps <c>POST /game/__dev/combat-targets-fixture</c> to reset prototype dummy HP (also enabled in Development/Testing).</summary>
|
||||
public bool EnableCombatTargetFixtureApi { get; set; }
|
||||
|
||||
/// <summary>World position for the dev player at process start.</summary>
|
||||
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -61,6 +62,13 @@ if (app.Environment.IsDevelopment() ||
|
|||
app.MapMasteryFixtureApi();
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
app.Configuration.GetValue<bool>("Game:EnableCombatTargetFixtureApi"))
|
||||
{
|
||||
app.MapCombatTargetFixtureApi();
|
||||
}
|
||||
|
||||
app.MapTargetingApi();
|
||||
app.MapHotbarLoadoutApi();
|
||||
app.MapCooldownSnapshotApi();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
}
|
||||
},
|
||||
"Game": {
|
||||
"EnableMasteryFixtureApi": true
|
||||
"EnableMasteryFixtureApi": true,
|
||||
"EnableCombatTargetFixtureApi": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,9 +110,40 @@ 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"
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
| Reason code | When |
|
||||
|-------------|------|
|
||||
| **`unknown_ability`** | **`abilityId`** failed registry normalization (empty/garbage/off-list). |
|
||||
| **`unknown_target`** | **`targetId`** not in **`PrototypeTargetRegistry`** (health store gate). |
|
||||
| **`target_defeated`** | Target HP is already **0** before resolve; no damage applied (includes zero-damage utility). |
|
||||
|
||||
**`CombatResult`** fields: **`success`**, **`reasonCode`** (`null` on success), **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**. On **`target_defeated`** deny, **`targetRemainingHp`** is **0** and **`targetDefeated`** on the envelope is **`false`** — use **`reasonCode`** for deny semantics.
|
||||
|
||||
Plan: [NEO-81 implementation plan](../../docs/plans/NEO-81-implementation-plan.md); cast wiring: [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82).
|
||||
|
||||
## Recipe definitions (NEO-68)
|
||||
|
||||
|
|
@ -531,11 +562,21 @@ Prototype server-owned hotbar bindings are available at:
|
|||
|
||||
Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`.
|
||||
|
||||
## Ability cast (NEO-31)
|
||||
## Ability cast (NEO-31, NEO-82)
|
||||
|
||||
Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**.
|
||||
Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**.
|
||||
|
||||
- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling; on accept, starts the prototype global cooldown for that slot. Returns **`AbilityCastResponse` v1** JSON (`schemaVersion`, `accepted`, optional `reasonCode`).
|
||||
- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**.
|
||||
|
||||
**Accept payload (`combatResolution`, present only when `accepted: true`):**
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `abilityId` | Normalized ability id (matches request/binding). |
|
||||
| `targetId` | Normalized prototype target id (lowercase registry key). |
|
||||
| `damageDealt` | Catalog **`baseDamage`** applied (0 for utility abilities). |
|
||||
| `targetRemainingHp` | Authoritative HP after resolve. |
|
||||
| `targetDefeated` | `true` when post-resolve HP is zero. |
|
||||
|
||||
**HTTP status:**
|
||||
|
||||
|
|
@ -555,13 +596,14 @@ Prototype **cast intent** (no full combat resolution — see E5.M1). **NEO-28**
|
|||
| `unknown_ability` | `abilityId` failed registry normalization (empty/garbage/off-list). |
|
||||
| `invalid_target` | `targetId` missing/empty, unknown prototype id, or not equal to the server's locked target id (NEO-28). |
|
||||
| `out_of_range` | Locked target is outside horizontal reach of authoritative player position vs. prototype anchor (NEO-28). |
|
||||
| `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful accept (NEO-32). |
|
||||
| `on_cooldown` | Slot is still inside the server-authoritative cooldown window after a prior successful combat resolve (NEO-32). |
|
||||
| `target_defeated` | Target HP is already zero; combat engine deny on re-hit (NEO-82 / NEO-81). No cooldown committed. |
|
||||
|
||||
Implementation: `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs`, `AbilityCastDtos.cs`. Optional **Slice 3–named** one-line stdout per JSON response when **`NEON_SPRAWL_API_LOG`** is set (see **Optional prototype API stdout** under **Run** above).
|
||||
|
||||
## Cooldown snapshot (NEO-32)
|
||||
|
||||
Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **Global duration** `AbilityPrototypeCooldown.GlobalDuration` applies on each successful cast after all NEO-28 gates pass.
|
||||
Prototype **per-slot cooldown ends** (in-memory per process; not persisted to Postgres). **NEO-82:** duration comes from ability catalog **`cooldownSeconds`** on each successful combat resolve (e.g. **`prototype_pulse`** 3.0s, **`prototype_guard`** 6.0s).
|
||||
|
||||
- **`GET /game/players/{id}/cooldown-snapshot`** → `CooldownSnapshotResponse` v1 (`schemaVersion`, `playerId`, `serverTimeUtc`, `slots[]` with `slotIndex` and optional `cooldownEndsAtUtc` per slot). **404** when the player id is unknown to position state (same rule as loadout GET).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue