Compare commits
64 Commits
25876a16fd
...
cb65612f03
| Author | SHA1 | Date |
|---|---|---|
|
|
cb65612f03 | |
|
|
48535f6bc2 | |
|
|
0b23e4d854 | |
|
|
d3e765fb8f | |
|
|
c757b7a385 | |
|
|
8fb7d842b0 | |
|
|
3134bb6ff6 | |
|
|
f4fafea7e2 | |
|
|
e25b1406c7 | |
|
|
70dc293246 | |
|
|
7e4e0ca843 | |
|
|
b611c048c5 | |
|
|
ba776b9b33 | |
|
|
3ad8581411 | |
|
|
c56ad22f6f | |
|
|
83282e76f0 | |
|
|
7730bde353 | |
|
|
b9e4aebb20 | |
|
|
ba31d8c420 | |
|
|
24cbd9baa8 | |
|
|
033d8f6cfa | |
|
|
6fd9694ff4 | |
|
|
846da90219 | |
|
|
5b4bbf5fac | |
|
|
7951a6c148 | |
|
|
dab67df0ce | |
|
|
75d11bd3cd | |
|
|
3af98fa208 | |
|
|
178097c603 | |
|
|
8d4c048bbe | |
|
|
d5fc648dd6 | |
|
|
b97cace625 | |
|
|
01741fed07 | |
|
|
8190614350 | |
|
|
c82724a4f2 | |
|
|
0600233c22 | |
|
|
87ec781edc | |
|
|
652a6459a8 | |
|
|
f5ff0b8dc3 | |
|
|
2316303ad7 | |
|
|
9c8e8c2109 | |
|
|
b90fedf7a8 | |
|
|
bf9fd84b1c | |
|
|
4d32d068dc | |
|
|
79b8a68364 | |
|
|
c8f6884ea0 | |
|
|
35ff76b532 | |
|
|
504d3228dd | |
|
|
7466f2bcba | |
|
|
faf3fe1139 | |
|
|
493e963a28 | |
|
|
f03e689ce9 | |
|
|
b65bff2a36 | |
|
|
3cbbcb6c2f | |
|
|
e61ee333ee | |
|
|
667a1a960a | |
|
|
fb2bfe5614 | |
|
|
a216219aab | |
|
|
adc34e0a9f | |
|
|
32fbcec766 | |
|
|
9d9b53332f | |
|
|
1e06b7174d | |
|
|
71a6761f3c | |
|
|
30d379c98c |
|
|
@ -0,0 +1,123 @@
|
|||
meta {
|
||||
name: GET gig progression after defeat spine (NEO-44)
|
||||
type: http
|
||||
seq: 31
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-44 AC spine: pre-request resets prototype target HP (NEO-83 fixture), then four pulse casts on alpha (slot 2 — avoids slot 0/1 cooldown from ability-cast and combat-targets folders).
|
||||
Asserts breach xp increased by 25 from pre-spine baseline; salvage/refine skill xp unchanged.
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
const skillBefore = await axios.get(
|
||||
`${baseUrl}/game/players/${playerId}/skill-progression`,
|
||||
jsonHeaders,
|
||||
);
|
||||
bru.setVar("neo44SalvageXpBefore", String(skillBefore.data.skills.find((x) => x.id === "salvage").xp));
|
||||
bru.setVar("neo44RefineXpBefore", String(skillBefore.data.skills.find((x) => x.id === "refine").xp));
|
||||
|
||||
const gigBefore = await axios.get(
|
||||
`${baseUrl}/game/players/${playerId}/gig-progression`,
|
||||
jsonHeaders,
|
||||
);
|
||||
const breachBefore = gigBefore.data.gigs.find((x) => x.id === "breach");
|
||||
bru.setVar(
|
||||
"neo44BreachXpBefore",
|
||||
String(breachBefore?.xp ?? 0),
|
||||
);
|
||||
|
||||
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: 2, 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: 2,
|
||||
abilityId: "prototype_pulse",
|
||||
targetId: "prototype_target_alpha",
|
||||
};
|
||||
|
||||
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(`neo44 cast ${i + 1} failed: ${JSON.stringify(body)}`);
|
||||
}
|
||||
if (i < 3) {
|
||||
await sleep(3200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/gig-progression
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const skillAfter = await axios.get(
|
||||
`${baseUrl}/game/players/${playerId}/skill-progression`,
|
||||
);
|
||||
bru.setVar("neo44SkillAfterJson", JSON.stringify(skillAfter.data));
|
||||
}
|
||||
|
||||
tests {
|
||||
test("breach gig xp increases by 25 after alpha defeat", function () {
|
||||
const body = res.getBody();
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
expect(body.mainGigId).to.equal("breach");
|
||||
const breach = body.gigs.find((x) => x.id === "breach");
|
||||
expect(breach).to.be.an("object");
|
||||
const breachXpBefore = Number(bru.getVar("neo44BreachXpBefore"));
|
||||
expect(breach.xp).to.equal(breachXpBefore + 25);
|
||||
expect(breach.level).to.equal(1);
|
||||
});
|
||||
|
||||
test("salvage and refine skill xp unchanged by combat defeat", function () {
|
||||
const skillAfter = JSON.parse(bru.getVar("neo44SkillAfterJson"));
|
||||
const salvageBefore = Number(bru.getVar("neo44SalvageXpBefore"));
|
||||
const refineBefore = Number(bru.getVar("neo44RefineXpBefore"));
|
||||
const salvageAfter = skillAfter.skills.find((x) => x.id === "salvage").xp;
|
||||
const refineAfter = skillAfter.skills.find((x) => x.id === "refine").xp;
|
||||
expect(salvageAfter).to.equal(salvageBefore);
|
||||
expect(refineAfter).to.equal(refineBefore);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
meta {
|
||||
name: GET gig progression
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-44: prototype main gig breach; xp 0 on fresh in-memory server (Postgres may retain prior totals until reset).
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/gig-progression
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 JSON with schema v1 and breach row", 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.playerId).to.equal("dev-local-1");
|
||||
expect(body.mainGigId).to.equal("breach");
|
||||
expect(body.gigs).to.be.an("array");
|
||||
expect(body.gigs.length).to.equal(1);
|
||||
const breach = body.gigs[0];
|
||||
expect(breach.id).to.equal("breach");
|
||||
expect(breach.xp).to.be.a("number");
|
||||
expect(breach.level).to.equal(1);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
meta {
|
||||
name: Gig progression folder
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-44: read-only gig XP snapshot (combat defeat path — not E2.M2 skill XP).
|
||||
Run order (folder runs after ability-cast + combat-targets alphabetically): Get gig progression → Get gig progression after defeat spine (~13s pre-request).
|
||||
Defeat spine resets prototype target HP via __dev/combat-targets-fixture (same as combat-targets folder) and uses slot 2 to avoid cooldown pollution from earlier folders.
|
||||
Cross-link: bruno/neon-sprawl-server/ability-cast/ and combat-targets/
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-63: uses prototype_urban_bulk_delta (not alpha) so earlier interaction requests in this collection do not pre-deplete capacity. Pre-request exhausts the node (10 successful gathers), then this interact should deny with node_depleted.
|
||||
NEO-63: uses prototype_urban_bulk_delta (not alpha) so earlier interaction requests in this collection do not pre-deplete capacity. Pre-request gathers until node_depleted (craft spine may consume one gather first). POST interact should deny with node_depleted.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
@ -21,7 +21,7 @@ script:pre-request {
|
|||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
while (true) {
|
||||
const res = await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/interact`,
|
||||
{
|
||||
|
|
@ -30,9 +30,13 @@ script:pre-request {
|
|||
},
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
if (!res.data.allowed) {
|
||||
throw new Error(`expected gather ${i + 1}/10 to succeed, got ${res.data.reasonCode}`);
|
||||
if (res.data?.allowed === true) {
|
||||
continue;
|
||||
}
|
||||
if (res.data?.reasonCode === "node_depleted") {
|
||||
break;
|
||||
}
|
||||
throw new Error(`expected gather to succeed or end with node_depleted, got ${JSON.stringify(res.data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (npc behavior catalog boot NEO-88)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-88 loads content/npc-behaviors/*_npc_behaviors.json at startup (fail-fast). No NPC behavior HTTP API in this story — use this request to confirm the host started after catalog validation.
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("service identity", function () {
|
||||
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: npc-behavior-catalog
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
meta {
|
||||
name: GET npc behavior definitions
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/world/npc-behavior-definitions
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 JSON with schema v1 and npcBehaviors 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.npcBehaviors).to.be.an("array");
|
||||
expect(body.npcBehaviors.length).to.equal(3);
|
||||
});
|
||||
|
||||
test("npcBehaviors are ascending by id (ordinal)", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.npcBehaviors.map((x) => x.id);
|
||||
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
expect(ids).to.eql(sorted);
|
||||
});
|
||||
|
||||
test("frozen prototype three matches registry id order", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.npcBehaviors.map((x) => x.id);
|
||||
expect(ids).to.eql([
|
||||
"prototype_elite_mini_boss",
|
||||
"prototype_melee_pressure",
|
||||
"prototype_ranged_control",
|
||||
]);
|
||||
});
|
||||
|
||||
test("prototype_melee_pressure row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.npcBehaviors.find((x) => x.id === "prototype_melee_pressure");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Melee Pressure");
|
||||
expect(row.archetypeKind).to.equal("melee_pressure");
|
||||
expect(row.maxHp).to.equal(100);
|
||||
expect(row.aggroRadius).to.equal(8);
|
||||
expect(row.leashRadius).to.equal(16);
|
||||
expect(row.telegraphWindupSeconds).to.equal(1.5);
|
||||
expect(row.attackDamage).to.equal(15);
|
||||
expect(row.attackCooldownSeconds).to.equal(3);
|
||||
});
|
||||
|
||||
test("prototype_elite_mini_boss row matches catalog", function () {
|
||||
const body = res.getBody();
|
||||
const row = body.npcBehaviors.find((x) => x.id === "prototype_elite_mini_boss");
|
||||
expect(row).to.be.an("object");
|
||||
expect(row.displayName).to.equal("Elite Mini-Boss");
|
||||
expect(row.archetypeKind).to.equal("elite_mini_boss");
|
||||
expect(row.maxHp).to.equal(200);
|
||||
expect(row.aggroRadius).to.equal(8);
|
||||
expect(row.leashRadius).to.equal(18);
|
||||
expect(row.telegraphWindupSeconds).to.equal(2.5);
|
||||
expect(row.attackDamage).to.equal(25);
|
||||
expect(row.attackCooldownSeconds).to.equal(5);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: npc-behavior-definitions
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
|
|||
- **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, one **display-only** line per known target in the form `<id>: <dist> m / <radius> (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: <reasonCode>` on the last line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response. Distance lines are a lag diagnostic: if the HUD shows `Validity: ok` but `alpha: 7.1 m / 6.0 (out)`, the server's position snapshot is lagging the visible capsule, and the next `authoritative_ack` refresh will reconcile. `UICanvas/PlayerPositionLabel` also shows the last **server-acknowledged** world position + horizontal delta from the visible capsule — when that `Δ` is large, targeting denials are being evaluated against a stale server snapshot.
|
||||
- **`positionHint` on select (NEO-24 follow-up #5):** when `TargetSelectionClient.set_freshness_kick(authority, player)` is wired (as `main.gd` does), every `POST /target/select` body also carries `positionHint: {x,y,z}` = the live capsule position. The server uses the hint for the radius check and for the `validity` field in the echoed `targetState`, eliminating the cross-request race between `move-stream` and `target/select` POSTs — a Tab pressed several seconds after stopping (with an old stored snap on the server) now still succeeds when you are visibly in range. The hint is advisory (server never writes it to the position store); `move-stream` remains the sole write path. Tests that omit `set_freshness_kick(...)` fall back to the no-hint code path.
|
||||
- **Freshness kick (ancillary):** alongside the hint, `set_freshness_kick(...)` also has the target client nudge the authority with `submit_stream_targets([player.global_position])` before each select POST. That write is no longer load-bearing for denial correctness (the hint handles that), but it keeps the stored snap reasonably fresh so the movement-triggered validity GET that runs on the next `authoritative_ack` sees current data.
|
||||
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a small colored mast + a flat translucent ring at every `ANCHORS` entry so the lock radius is visible in-world. All prototype targets share a single radius (`PrototypeTargetConstants.SHARED_LOCK_RADIUS` = `PrototypeTargetRegistry.SharedLockRadius` = **6 m**); anchors are placed at `(-3, 0.5, -3)` and `(3, 0, 3)` so the two rings overlap at origin (~3.5 m wide), letting you flip between targets with Tab without locomotion. The script and `prototype_target_constants.gd` mirror the server's `PrototypeTargetRegistry.cs` — change them together in the same commit so the markers never drift from server authority. Per-target radii will return with real combat design (E5.M1).
|
||||
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a **capsule dummy body**, floating **Label3D** name (**Dummy α** / **Dummy β**), and a flat translucent **lock ring** at every `ANCHORS` entry. **Orange** = `prototype_target_alpha` at **`(-3, 0.5, -3)`**; **purple** = `prototype_target_beta` at **`(3, 0, 3)`** — both near default spawn **`(-5, 0.9, -5)`** with overlapping 6 m rings at the origin. **Tab** locks a dummy; the locked capsule + ring **brighten**. All prototype targets share `PrototypeTargetConstants.SHARED_LOCK_RADIUS` = **6 m** (mirrors `PrototypeTargetRegistry.cs`). Keep client constants and server registry in the same commit when anchors move.
|
||||
- **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
|
||||
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.
|
||||
|
||||
|
|
@ -112,7 +112,7 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
|
|||
- **`TargetSelectionClient`** emits **`selection_event(event: Dictionary)`** only when the server-acknowledged **`lockedTargetId`** changes (not on **`validity`-only** updates; those stay on **`target_state_changed`**).
|
||||
- Payload keys: **`previous`** ([String] or `null`), **`next`** (same), **`cause`** (`tab` \| `clear` \| `server_correction`), **`sequence`** (int from the new state). **`POST …/target/select`** from **`request_select_target_id`** (including the path used by Tab) uses cause **`tab`**; **`request_clear_target`** uses **`clear`**; every **`GET …/target`** completion uses **`server_correction`** (including boot sync).
|
||||
- **Slice 3 telemetry hook (NEO-27):** this selection transition maps to product event name **`target_changed`**; with **`log_selection_events`** enabled, Godot Output prints the `target_changed` token for manual verification. **TODO(E9.M1):** replace dev log with cataloged telemetry emit.
|
||||
- **Ability cast hooks (NEO-27 + NEO-31 + NEO-28):** digit keys **`hotbar_slot_1` … `hotbar_slot_8`** (defaults **1**–**8**) issue a prototype cast via `scripts/ability_cast_client.gd`. **`ability_cast_requested`** is printed from `main.gd` only when **`request_cast`** returns **`true`** (HTTP POST successfully queued). **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses, and **NEO-28** mirrors the same outcome on the **`CastFeedbackLabel`** HUD line (`ability_cast_denied: <reasonCode>` or `Cast: accepted`) via **`AbilityCastClient.cast_result_received`** (TODO(E9.M1): telemetry).
|
||||
- **Ability cast hooks (NEO-27 + NEO-31 + NEO-28):** digit keys **`hotbar_slot_1` … `hotbar_slot_8`** (defaults **1**–**8**) issue a prototype cast via `scripts/ability_cast_client.gd`. **`ability_cast_requested`** is printed from `main.gd` only when **`request_cast`** returns **`true`** (HTTP POST successfully queued). **`ability_cast_denied`** is logged as `push_warning` from the client on deny responses, and **NEO-28** mirrors the same outcome on the **`CastFeedbackLabel`** HUD line (`ability_cast_denied: <reasonCode>` or combat resolution copy on accept) via **`AbilityCastClient.cast_result_received`** (TODO(E9.M1): telemetry).
|
||||
- **E1.M4+:** connect to **`$TargetSelectionClient.selection_event`** (or your scene’s path to that node) without reshaping the payload.
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-26.md`](../docs/manual-qa/NEO-26.md).
|
||||
|
|
@ -134,6 +134,46 @@ This story hydrates bindings only; cast execution and cooldown behavior stay in
|
|||
|
||||
Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cast deny/accept HUD: [`docs/manual-qa/NEO-28.md`](../docs/manual-qa/NEO-28.md).
|
||||
|
||||
## Combat feedback + target HP HUD (NEO-85)
|
||||
|
||||
- **`POST /game/players/{id}/ability-cast`** accept responses include nested **`combatResolution`** (`damageDealt`, `targetRemainingHp`, `targetDefeated`, …) — see [server README — Ability cast](../server/README.md).
|
||||
- **`GET /game/world/combat-targets`** — authoritative dummy HP snapshot for the locked target label (NEO-83); no client-side damage math.
|
||||
- **Scripts:** `scripts/ability_cast_client.gd` (parses **`combatResolution`** on accept), `scripts/combat_targets_client.gd`; wired from `main.gd` in `_setup_hotbar_loadout_sync()` / `_setup_combat_targets_sync()`.
|
||||
- **HUD:**
|
||||
- **`UICanvas/CastFeedbackLabel`** — on accept: **`Cast: {damage} dmg → {remaining} HP`** or **`… — defeated!`** when **`targetDefeated`**; denies unchanged from NEO-28.
|
||||
- **`UICanvas/CombatTargetHpLabel`** — locked target **`currentHp/maxHp`** (+ **` (defeated)`**); **`Target HP: — (Tab → orange/purple dummy near spawn)`** when unlocked.
|
||||
- **World dummies:** **`World/PrototypeTargetMarkers`** — orange **Dummy α** west of spawn, purple **Dummy β** south-east; **Tab** locks and highlights the active dummy (see Targeting section above).
|
||||
- **Refresh (event-driven):** combat-target GET after successful cast accept and when **`lockedTargetId`** changes (Tab lock / Esc clear / server correction). No periodic HP poll.
|
||||
- **Dev hotbar bootstrap (NEO-85):** on first hotbar sync, if slot **0** is empty the client POSTs a bind for **`prototype_pulse`** once (same preset as Bruno `Post hotbar bind slot0 pulse`) so digit **1** works without a manual bind step when the server is up.
|
||||
- **Dev fixture reset:** when the server exposes **`POST /game/__dev/combat-targets-fixture`** (Development/Testing), reset dummy HP between manual QA runs — see [server README](../server/README.md#combat-entity-health-store-neo-80).
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-85.md`](../docs/manual-qa/NEO-85.md).
|
||||
|
||||
## End-to-end combat loop (NEO-86)
|
||||
|
||||
Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility **in Godot** without Bruno.
|
||||
|
||||
**Flow:** boot hotbar sync (auto **`prototype_pulse`** bind on slot 0 when empty) → walk to orange **Dummy α** → **Tab** lock → digit **1** cast until defeat → verify combat HUD + **`breach`** gig XP refresh.
|
||||
|
||||
| Step | Input / trigger | HUD / server outcome |
|
||||
|------|-----------------|----------------------|
|
||||
| Boot | Godot **F5** + server running | Hotbar slot 0 binds **`prototype_pulse`**; **`GigXpLabel`** hydrates **`breach: L1 · 0 xp`** |
|
||||
| Lock | **Tab** on **`prototype_target_alpha`** | Dummy brightens; **`CombatTargetHpLabel`** shows **`100/100`** |
|
||||
| Cast ×4 | **1** (respect ~3s cooldown) | **`CastFeedbackLabel`** steps damage; HP label tracks server snapshot |
|
||||
| Defeat | 4th pulse accept | **`Cast: … — defeated!`**; HP **`0/100 (defeated)`**; **`GigXpLabel`** → **`25 xp`** |
|
||||
| Deny | **1** on defeated target | **`ability_cast_denied: target_defeated`** |
|
||||
| Clear | **Esc** | HP label **`— (no lock)`** |
|
||||
|
||||
**Cross-links:** [NEO-23](../docs/plans/NEO-23-implementation-plan.md) targeting · [NEO-28](../docs/manual-qa/NEO-28.md) cast deny HUD · [NEO-31](../docs/manual-qa/NEO-31.md) cast POST · [NEO-85](../docs/manual-qa/NEO-85.md) combat feedback + HP · [NEO-44](../docs/manual-qa/NEO-44.md) server gig grant.
|
||||
|
||||
**Scripts:** `ability_cast_client.gd`, `combat_targets_client.gd`, `gig_progression_client.gd` — wired from `main.gd`.
|
||||
|
||||
**Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit.
|
||||
|
||||
**Preconditions:** **Server restart** before capstone run resets in-memory dummy HP and gig XP baseline.
|
||||
|
||||
Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.md).
|
||||
|
||||
## Inventory snapshot HUD (NEO-72)
|
||||
|
||||
- **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http).
|
||||
|
|
@ -242,7 +282,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
|
|||
|
||||
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
|
||||
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
|
||||
|
||||
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
[ext_resource type="Script" path="res://scripts/craft_client.gd" id="18_craft"]
|
||||
[ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"]
|
||||
[ext_resource type="Script" path="res://scripts/prototype_economy_hud_section.gd" id="20_economy_hud"]
|
||||
[ext_resource type="Script" path="res://scripts/gig_progression_client.gd" id="21_gig_prog"]
|
||||
|
||||
[sub_resource type="NavigationMesh" id="NavigationMesh_district"]
|
||||
geometry_collision_mask = 1
|
||||
|
|
@ -1105,6 +1106,9 @@ script = ExtResource("15_inventory")
|
|||
[node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008]
|
||||
script = ExtResource("16_skill_prog")
|
||||
|
||||
[node name="GigProgressionClient" type="Node" parent="." unique_id=2500011]
|
||||
script = ExtResource("21_gig_prog")
|
||||
|
||||
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
|
||||
script = ExtResource("17_recipe_defs")
|
||||
|
||||
|
|
@ -1170,11 +1174,25 @@ theme_override_font_sizes/font_size = 15
|
|||
autowrap_mode = 3
|
||||
text = "Cast: —"
|
||||
|
||||
[node name="CombatTargetHpLabel" type="Label" parent="UICanvas" unique_id=9000018]
|
||||
offset_left = 8.0
|
||||
offset_top = 374.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 398.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.92, 0.78, 0.82, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_font_sizes/font_size = 15
|
||||
autowrap_mode = 3
|
||||
text = "Target HP: — (Tab → dummy)"
|
||||
|
||||
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008]
|
||||
offset_left = 8.0
|
||||
offset_top = 376.0
|
||||
offset_top = 402.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 402.0
|
||||
offset_bottom = 428.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1)
|
||||
|
|
@ -1186,9 +1204,9 @@ text = "Gather: —"
|
|||
|
||||
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010]
|
||||
offset_left = 8.0
|
||||
offset_top = 404.0
|
||||
offset_top = 430.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 430.0
|
||||
offset_bottom = 456.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1)
|
||||
|
|
@ -1200,9 +1218,9 @@ text = "Craft: —"
|
|||
|
||||
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
|
||||
offset_left = 8.0
|
||||
offset_top = 436.0
|
||||
offset_top = 462.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 512.0
|
||||
offset_bottom = 538.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
|
||||
|
|
@ -1214,9 +1232,9 @@ text = "Cooldowns: —"
|
|||
|
||||
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014]
|
||||
offset_left = 8.0
|
||||
offset_top = 524.0
|
||||
offset_top = 550.0
|
||||
offset_right = 520.0
|
||||
offset_bottom = 1056.0
|
||||
offset_bottom = 1082.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
script = ExtResource("20_economy_hud")
|
||||
|
|
@ -1255,6 +1273,18 @@ autowrap_mode = 3
|
|||
text = "Skills:
|
||||
Loading…"
|
||||
|
||||
[node name="GigXpLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000018]
|
||||
custom_minimum_size = Vector2(512, 48)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.88, 0.78, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_font_sizes/font_size = 14
|
||||
autowrap_mode = 3
|
||||
text = "Gig XP:
|
||||
Loading…"
|
||||
|
||||
[node name="CraftRecipePanel" type="Control" parent="UICanvas/EconomyHudSection/Body" unique_id=9000011]
|
||||
custom_minimum_size = Vector2(512, 280)
|
||||
layout_mode = 2
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ extends Node
|
|||
## NEO-31: prototype HTTP client for `POST /game/players/{id}/ability-cast`.
|
||||
## NEO-28: emits [signal cast_result_received] when the response body is JSON
|
||||
## [AbilityCastResponse] v1.
|
||||
## NEO-85: optional nested [code]combatResolution[/code] on accept (NEO-82 wire shape).
|
||||
## NEO-30: [code]ability_cast_denied[/code] dev hook ([code]push_warning[/code] below). Deny
|
||||
## [code]reasonCode[/code] is authoritative in [code]AbilityCastApi.cs[/code] (comment hook sites).
|
||||
signal cast_result_received(accepted: bool, reason_code: String)
|
||||
signal cast_result_received(accepted: bool, reason_code: String, resolution: Dictionary)
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
|
|
@ -65,6 +66,22 @@ func _player_path_segment() -> String:
|
|||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_cast_response_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var data: Dictionary = parsed
|
||||
var accepted: bool = bool(data.get("accepted", false))
|
||||
var reason_variant: Variant = data.get("reasonCode", "")
|
||||
var reason: String = reason_variant as String if reason_variant is String else ""
|
||||
var resolution: Dictionary = {}
|
||||
if accepted:
|
||||
var block: Variant = data.get("combatResolution", null)
|
||||
if block is Dictionary:
|
||||
resolution = block as Dictionary
|
||||
return {"accepted": accepted, "reasonCode": reason, "resolution": resolution}
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
|
|
@ -76,15 +93,15 @@ func _on_request_completed(
|
|||
push_warning("AbilityCastClient: HTTP %s" % response_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
var parsed: Variant = parse_cast_response_json(text)
|
||||
if parsed == null:
|
||||
push_warning("AbilityCastClient: non-JSON body")
|
||||
return
|
||||
var data: Dictionary = parsed
|
||||
var data: Dictionary = parsed as Dictionary
|
||||
var accepted: bool = bool(data.get("accepted", false))
|
||||
var reason_variant: Variant = data.get("reasonCode", "")
|
||||
var reason: String = reason_variant as String if reason_variant is String else ""
|
||||
cast_result_received.emit(accepted, reason)
|
||||
var reason: String = str(data.get("reasonCode", ""))
|
||||
var resolution: Dictionary = data.get("resolution", {}) as Dictionary
|
||||
cast_result_received.emit(accepted, reason, resolution)
|
||||
if not accepted:
|
||||
# NEO-27 / NEO-31 / NEO-28: product hook name for future telemetry (TODO(E9.M1)).
|
||||
push_warning("ability_cast_denied reasonCode=%s" % reason)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
extends Node
|
||||
|
||||
## NEO-85: prototype HTTP client for [code]GET /game/world/combat-targets[/code] (NEO-83).
|
||||
|
||||
signal targets_received(snapshot: Dictionary)
|
||||
signal targets_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _last_snapshot: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/world/combat-targets" % _base_root()
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("CombatTargetsClient: %s" % reason)
|
||||
_busy = false
|
||||
targets_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func target_row(target_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var source: Dictionary = snapshot
|
||||
if source.is_empty():
|
||||
source = _last_snapshot
|
||||
var targets: Variant = source.get("targets", null)
|
||||
if targets == null or not targets is Array:
|
||||
return {}
|
||||
for row_variant in targets as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("targetId", "")) == target_id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
static func parse_targets_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
var targets: Variant = root.get("targets", null)
|
||||
if targets == null or not targets is Array:
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("CombatTargetsClient: %s" % reason)
|
||||
targets_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("CombatTargetsClient: %s" % reason_code)
|
||||
targets_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_targets_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("CombatTargetsClient: %s" % reason_json)
|
||||
targets_sync_failed.emit(reason_json)
|
||||
return
|
||||
_last_snapshot = snapshot as Dictionary
|
||||
targets_received.emit(_last_snapshot)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo85combtgt01
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
extends Node
|
||||
|
||||
## NEO-86: prototype HTTP client for [code]GET /game/players/{id}/gig-progression[/code] (NEO-44).
|
||||
|
||||
signal progression_received(snapshot: Dictionary)
|
||||
signal progression_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/players/%s/gig-progression" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("GigProgressionClient: %s" % reason)
|
||||
_busy = false
|
||||
progression_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func gig_row(gig_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var gigs: Variant = snapshot.get("gigs", null)
|
||||
if gigs == null or not gigs is Array:
|
||||
return {}
|
||||
for row_variant in gigs as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) == gig_id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_progression_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
var gigs: Variant = root.get("gigs", null)
|
||||
if gigs == null or not gigs is Array:
|
||||
return null
|
||||
if str(root.get("mainGigId", "")).strip_edges().is_empty():
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("GigProgressionClient: %s" % reason)
|
||||
progression_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("GigProgressionClient: %s" % reason404)
|
||||
progression_sync_failed.emit(reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("GigProgressionClient: %s" % reason_code)
|
||||
progression_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_progression_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("GigProgressionClient: %s" % reason_json)
|
||||
progression_sync_failed.emit(reason_json)
|
||||
return
|
||||
progression_received.emit(snapshot as Dictionary)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo86gigprog01
|
||||
|
|
@ -43,9 +43,11 @@ const PrototypeInteractablePicker := preload("res://scripts/prototype_interactab
|
|||
const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd")
|
||||
const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
|
||||
const COOLDOWN_REASON_ON_CD := "on_cooldown"
|
||||
const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse"
|
||||
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
|
||||
const SALVAGE_SKILL_ID := "salvage"
|
||||
const REFINE_SKILL_ID := "refine"
|
||||
const BREACH_GIG_ID := "breach"
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -75,6 +77,8 @@ var _dev_obstacle_smoke: Node3D
|
|||
var _hotbar_state: Node = null
|
||||
var _hotbar_client: Node = null
|
||||
var _ability_cast_client: Node = null
|
||||
var _combat_targets_client: Node = null
|
||||
var _combat_targets_snapshot: Dictionary = {}
|
||||
var _cooldown_state: RefCounted = null
|
||||
var _cooldown_client: Node = null
|
||||
var _cooldown_poll_timer: Timer = null
|
||||
|
|
@ -83,11 +87,14 @@ var _inventory_error: String = ""
|
|||
var _interactables_catalog: Array = []
|
||||
var _last_progression_snapshot: Dictionary = {}
|
||||
var _progression_error: String = ""
|
||||
var _last_gig_snapshot: Dictionary = {}
|
||||
var _gig_error: String = ""
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
var _cached_recipe_rows: Array = []
|
||||
var _recipe_defs_error: String = ""
|
||||
var _dev_pulse_bootstrap_attempted: bool = false
|
||||
|
||||
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
|
||||
@onready var _world: Node3D = $World
|
||||
|
|
@ -101,6 +108,7 @@ var _recipe_defs_error: String = ""
|
|||
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
|
||||
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
|
||||
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel
|
||||
@onready var _combat_target_hp_label: Label = $UICanvas/CombatTargetHpLabel
|
||||
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
|
||||
@onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection
|
||||
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel")
|
||||
|
|
@ -108,13 +116,16 @@ var _recipe_defs_error: String = ""
|
|||
@onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel
|
||||
@onready
|
||||
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel")
|
||||
@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel")
|
||||
@onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("Body/CraftRecipePanel")
|
||||
@onready var _inventory_client: Node = $InventoryClient
|
||||
@onready var _item_defs_client: Node = $ItemDefinitionsClient
|
||||
@onready var _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _gig_progression_client: Node = $GigProgressionClient
|
||||
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
@onready var _target_markers: Node3D = $World/PrototypeTargetMarkers
|
||||
@onready var _interaction_client: Node = $InteractionRequestClient
|
||||
@onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor
|
||||
|
||||
|
|
@ -157,6 +168,7 @@ func _ready() -> void:
|
|||
_setup_hotbar_loadout_sync()
|
||||
_setup_inventory_sync()
|
||||
_setup_skill_progression_sync()
|
||||
_setup_gig_progression_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
_setup_craft_ui()
|
||||
|
||||
|
|
@ -265,9 +277,16 @@ func _on_authoritative_ack_for_hud(world: Vector3) -> void:
|
|||
## live distance readouts stay in sync with the player capsule even while
|
||||
## the server state is idle.
|
||||
func _on_target_state_changed(state: Dictionary) -> void:
|
||||
var prev_lock := _locked_target_id_from_state(_last_target_state)
|
||||
_last_target_state = state.duplicate()
|
||||
if is_instance_valid(_player):
|
||||
_render_target_lock_label(_player.global_position)
|
||||
_render_combat_target_hp_label()
|
||||
var new_lock := _locked_target_id_from_state(_last_target_state)
|
||||
if is_instance_valid(_target_markers) and _target_markers.has_method("set_locked_target"):
|
||||
_target_markers.call("set_locked_target", new_lock)
|
||||
if new_lock != prev_lock:
|
||||
_request_combat_targets_refresh()
|
||||
|
||||
|
||||
func _setup_hotbar_loadout_sync() -> void:
|
||||
|
|
@ -298,9 +317,22 @@ func _setup_hotbar_loadout_sync() -> void:
|
|||
_hotbar_client.call("set_hotbar_state", _hotbar_state)
|
||||
if _hotbar_client.has_method("request_sync_from_server"):
|
||||
_hotbar_client.call("request_sync_from_server")
|
||||
_setup_combat_targets_sync()
|
||||
_setup_cooldown_sync()
|
||||
|
||||
|
||||
func _setup_combat_targets_sync() -> void:
|
||||
# NEO-85: authoritative combat dummy HP for locked target HUD line.
|
||||
_combat_targets_client = load("res://scripts/combat_targets_client.gd").new()
|
||||
_combat_targets_client.name = "CombatTargetsClient"
|
||||
add_child(_combat_targets_client)
|
||||
_apply_authority_http_config_to_client(_combat_targets_client)
|
||||
if _combat_targets_client.has_signal("targets_received"):
|
||||
_combat_targets_client.connect(
|
||||
"targets_received", Callable(self, "_on_combat_targets_received")
|
||||
)
|
||||
|
||||
|
||||
func _setup_cooldown_sync() -> void:
|
||||
# NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard.
|
||||
_cooldown_state = CooldownStateScript.new()
|
||||
|
|
@ -319,9 +351,7 @@ func _setup_cooldown_sync() -> void:
|
|||
"snapshot_received", Callable(self, "_on_cooldown_snapshot_received")
|
||||
)
|
||||
if _hotbar_client.has_signal("loadout_changed"):
|
||||
_hotbar_client.connect(
|
||||
"loadout_changed", Callable(self, "_on_hotbar_loadout_changed_sync_cooldown")
|
||||
)
|
||||
_hotbar_client.connect("loadout_changed", Callable(self, "_on_hotbar_loadout_changed"))
|
||||
_ensure_cooldown_poll_timer()
|
||||
|
||||
|
||||
|
|
@ -477,6 +507,22 @@ func _setup_skill_progression_sync() -> void:
|
|||
_skill_progression_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_gig_progression_sync() -> void:
|
||||
# NEO-86: breach gig XP HUD row; boot hydrate + refresh after defeat cast.
|
||||
_apply_authority_http_config_to_client(_gig_progression_client)
|
||||
if _gig_progression_client.has_signal("progression_received"):
|
||||
_gig_progression_client.connect(
|
||||
"progression_received", Callable(self, "_on_gig_progression_received")
|
||||
)
|
||||
if _gig_progression_client.has_signal("progression_sync_failed"):
|
||||
_gig_progression_client.connect(
|
||||
"progression_sync_failed", Callable(self, "_on_gig_progression_sync_failed")
|
||||
)
|
||||
_render_gig_xp_label()
|
||||
if _gig_progression_client.has_method("request_sync_from_server"):
|
||||
_gig_progression_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_gather_interact_feedback() -> void:
|
||||
if not is_instance_valid(_interaction_client):
|
||||
return
|
||||
|
|
@ -540,6 +586,53 @@ func _skill_row(skill_id: String) -> Dictionary:
|
|||
return row as Dictionary
|
||||
|
||||
|
||||
func _on_gig_progression_received(snapshot: Dictionary) -> void:
|
||||
_gig_error = ""
|
||||
_last_gig_snapshot = snapshot.duplicate(true)
|
||||
_render_gig_xp_label()
|
||||
|
||||
|
||||
func _on_gig_progression_sync_failed(reason: String) -> void:
|
||||
_gig_error = reason
|
||||
_last_gig_snapshot = {}
|
||||
_render_gig_xp_label()
|
||||
|
||||
|
||||
func _render_gig_xp_label() -> void:
|
||||
if not is_instance_valid(_gig_xp_label):
|
||||
return
|
||||
var header := "Gig XP:"
|
||||
if not _gig_error.is_empty():
|
||||
_gig_xp_label.text = "%s\nerror — %s" % [header, _gig_error]
|
||||
return
|
||||
if _last_gig_snapshot.is_empty():
|
||||
_gig_xp_label.text = "%s\nLoading…" % header
|
||||
return
|
||||
var breach_row: Dictionary = _gig_row(BREACH_GIG_ID)
|
||||
if breach_row.is_empty():
|
||||
_gig_xp_label.text = "%s\nbreach: — (missing row)" % header
|
||||
return
|
||||
var level: int = int(breach_row.get("level", 0))
|
||||
var xp: int = int(breach_row.get("xp", 0))
|
||||
_gig_xp_label.text = "%s\nbreach: L%d · %d xp" % [header, level, xp]
|
||||
|
||||
|
||||
func _gig_row(gig_id: String) -> Dictionary:
|
||||
if not is_instance_valid(_gig_progression_client):
|
||||
return {}
|
||||
if not _gig_progression_client.has_method("gig_row"):
|
||||
return {}
|
||||
var row: Variant = _gig_progression_client.call("gig_row", gig_id, _last_gig_snapshot)
|
||||
return row as Dictionary
|
||||
|
||||
|
||||
func _request_gig_progression_refresh() -> void:
|
||||
if not is_instance_valid(_gig_progression_client):
|
||||
return
|
||||
if _gig_progression_client.has_method("request_sync_from_server"):
|
||||
_gig_progression_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
||||
if is_instance_valid(_gather_feedback_label):
|
||||
_gather_feedback_label.text = text
|
||||
|
|
@ -728,6 +821,27 @@ func _finalize_pending_gather_feedback() -> void:
|
|||
_render_gather_feedback_label("Gather: +%d %s" % [delta, label])
|
||||
|
||||
|
||||
func _on_hotbar_loadout_changed(loadout: Dictionary) -> void:
|
||||
_on_hotbar_loadout_changed_sync_cooldown(loadout)
|
||||
_maybe_dev_bind_pulse_slot0()
|
||||
|
||||
|
||||
func _maybe_dev_bind_pulse_slot0() -> void:
|
||||
if _dev_pulse_bootstrap_attempted:
|
||||
return
|
||||
if not is_instance_valid(_hotbar_state) or not is_instance_valid(_hotbar_client):
|
||||
return
|
||||
var slots: Array = _hotbar_state.call("slots_snapshot") as Array
|
||||
if slots.is_empty():
|
||||
return
|
||||
var slot0: Variant = slots[0]
|
||||
if slot0 is String and not (slot0 as String).strip_edges().is_empty():
|
||||
return
|
||||
_dev_pulse_bootstrap_attempted = true
|
||||
if _hotbar_client.has_method("request_bind_slot"):
|
||||
_hotbar_client.call("request_bind_slot", 0, DEV_BOOTSTRAP_CAST_ABILITY_ID)
|
||||
|
||||
|
||||
func _on_hotbar_loadout_changed_sync_cooldown(_loadout: Dictionary) -> void:
|
||||
if (
|
||||
is_instance_valid(_cooldown_client)
|
||||
|
|
@ -802,14 +916,20 @@ func _render_target_lock_label(world: Vector3) -> void:
|
|||
_target_lock_label.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _on_cast_result_received(accepted: bool, reason_code: String) -> void:
|
||||
func _on_cast_result_received(accepted: bool, reason_code: String, resolution: Dictionary) -> void:
|
||||
if not is_instance_valid(_cast_feedback_label):
|
||||
return
|
||||
if accepted:
|
||||
_cast_feedback_label.text = "Cast: accepted"
|
||||
if not resolution.is_empty():
|
||||
_cast_feedback_label.text = _format_cast_resolution_feedback(resolution)
|
||||
else:
|
||||
_cast_feedback_label.text = "Cast: accepted"
|
||||
if is_instance_valid(_cooldown_client):
|
||||
if _cooldown_client.has_method("request_sync_from_server"):
|
||||
_cooldown_client.call("request_sync_from_server")
|
||||
_request_combat_targets_refresh()
|
||||
if bool(resolution.get("targetDefeated", false)):
|
||||
_request_gig_progression_refresh()
|
||||
return
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
|
|
@ -818,6 +938,60 @@ func _on_cast_result_received(accepted: bool, reason_code: String) -> void:
|
|||
_cast_feedback_label.text = "ability_cast_denied: %s" % rc
|
||||
|
||||
|
||||
func _format_cast_resolution_feedback(resolution: Dictionary) -> String:
|
||||
var damage: int = int(resolution.get("damageDealt", 0))
|
||||
var remaining: int = int(resolution.get("targetRemainingHp", 0))
|
||||
if bool(resolution.get("targetDefeated", false)):
|
||||
return "Cast: %d dmg → 0 HP — defeated!" % damage
|
||||
return "Cast: %d dmg → %d HP" % [damage, remaining]
|
||||
|
||||
|
||||
func _on_combat_targets_received(snapshot: Dictionary) -> void:
|
||||
_combat_targets_snapshot = snapshot
|
||||
_render_combat_target_hp_label()
|
||||
|
||||
|
||||
func _request_combat_targets_refresh() -> void:
|
||||
if not is_instance_valid(_combat_targets_client):
|
||||
return
|
||||
if _combat_targets_client.has_method("request_sync_from_server"):
|
||||
_combat_targets_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _locked_target_id_from_state(state: Dictionary) -> String:
|
||||
var locked_variant: Variant = state.get("lockedTargetId", null)
|
||||
if locked_variant is String:
|
||||
return (locked_variant as String).strip_edges()
|
||||
return ""
|
||||
|
||||
|
||||
func _render_combat_target_hp_label() -> void:
|
||||
if not is_instance_valid(_combat_target_hp_label):
|
||||
return
|
||||
var lock_id := _locked_target_id_from_state(_last_target_state)
|
||||
if lock_id.is_empty():
|
||||
_combat_target_hp_label.text = ("Target HP: — (Tab → orange/purple dummy near spawn)")
|
||||
return
|
||||
var row: Dictionary = {}
|
||||
if (
|
||||
is_instance_valid(_combat_targets_client)
|
||||
and _combat_targets_client.has_method("target_row")
|
||||
):
|
||||
row = (
|
||||
_combat_targets_client.call("target_row", lock_id, _combat_targets_snapshot)
|
||||
as Dictionary
|
||||
)
|
||||
if row.is_empty():
|
||||
_combat_target_hp_label.text = "Target HP: %s …" % lock_id
|
||||
return
|
||||
var current: int = int(row.get("currentHp", 0))
|
||||
var max_hp: int = int(row.get("maxHp", 0))
|
||||
var defeated_suffix := " (defeated)" if bool(row.get("defeated", false)) else ""
|
||||
_combat_target_hp_label.text = (
|
||||
"Target HP: %s %d/%d%s" % [lock_id, current, max_hp, defeated_suffix]
|
||||
)
|
||||
|
||||
|
||||
func _on_move_rejected(reason_code: String) -> void:
|
||||
_authority_force_snap_next = true
|
||||
# Rejected stream: server state may differ; next snap is forced.
|
||||
|
|
@ -910,6 +1084,9 @@ func _try_inventory_refresh_input(event: InputEvent) -> bool:
|
|||
|
||||
func _request_hotbar_cast_slot(slot_index: int) -> void:
|
||||
if not is_instance_valid(_hotbar_state) or not _hotbar_state.has_method("slots_snapshot"):
|
||||
if is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = "Hotbar: not ready"
|
||||
push_warning("Hotbar cast ignored: hotbar_state unavailable")
|
||||
return
|
||||
var slots: Array = _hotbar_state.call("slots_snapshot") as Array
|
||||
var target_state: Dictionary = {}
|
||||
|
|
@ -922,8 +1099,14 @@ func _request_hotbar_cast_slot(slot_index: int) -> void:
|
|||
var reason: String = str(outcome.get(HotbarCastSlotResolver.KEY_REASON, ""))
|
||||
if reason == HotbarCastSlotResolver.REASON_INVALID_SLOT:
|
||||
push_warning("Hotbar cast ignored: slot %d invalid_slot_index" % slot_index)
|
||||
if is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = "Hotbar: slot %d invalid" % (slot_index + 1)
|
||||
elif reason == HotbarCastSlotResolver.REASON_EMPTY_SLOT:
|
||||
push_warning("Hotbar cast ignored: slot %d empty_slot" % slot_index)
|
||||
if is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = (
|
||||
"Hotbar: slot %d empty (bind ability or wait for sync)" % (slot_index + 1)
|
||||
)
|
||||
return
|
||||
var ability_id: String = outcome[HotbarCastSlotResolver.KEY_ABILITY_ID] as String
|
||||
var resolved_slot: int = int(outcome.get(HotbarCastSlotResolver.KEY_SLOT_INDEX, slot_index))
|
||||
|
|
@ -951,6 +1134,8 @@ func _request_hotbar_cast_slot(slot_index: int) -> void:
|
|||
% [resolved_slot, ability_id, target_label]
|
||||
)
|
||||
)
|
||||
elif is_instance_valid(_cast_feedback_label):
|
||||
_cast_feedback_label.text = "Cast: failed to send (busy or server offline?)"
|
||||
|
||||
|
||||
func _forward_interact_post(method: String) -> void:
|
||||
|
|
|
|||
|
|
@ -115,6 +115,17 @@ static func next_in_range_id_after(current: Variant, world: Vector3) -> String:
|
|||
return ""
|
||||
|
||||
|
||||
## Player-facing short label for world [Label3D] markers (NEO-85 combat UX).
|
||||
static func display_short_name(target_id: String) -> String:
|
||||
match target_id:
|
||||
"prototype_target_alpha":
|
||||
return "Dummy α"
|
||||
"prototype_target_beta":
|
||||
return "Dummy β"
|
||||
_:
|
||||
return target_id.strip_edges()
|
||||
|
||||
|
||||
## Returns the next id in [constant ORDERED_IDS] after [param current]. Returns the **first** id
|
||||
## when [param current] is [code]null[/code], empty, or not found (start of cycle). Wraps from
|
||||
## last → first.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
extends Node3D
|
||||
|
||||
## NEO-24 UX: the targeting registry anchors (`prototype_target_alpha`,
|
||||
## `prototype_target_beta`) are invisible in the world, so "Tab says ok but I feel
|
||||
## far away" is impossible to reason about without debug markers. This node spawns
|
||||
## one small mast + a flat radius ring per id in
|
||||
## `PrototypeTargetConstants.ANCHORS`, so the player can see exactly where each
|
||||
## anchor sits and how big its lock radius is.
|
||||
## NEO-24 UX: combat dummy anchors (`prototype_target_alpha`, `prototype_target_beta`) need
|
||||
## obvious world markers — a capsule body, lock ring, and [Label3D] name per
|
||||
## `PrototypeTargetConstants.ANCHORS`. NEO-85: highlight the server-locked dummy via
|
||||
## [method set_locked_target].
|
||||
##
|
||||
## Display-only; no game logic hangs off these nodes. If the server registry moves,
|
||||
## update `prototype_target_constants.gd` — markers respawn on next scene load.
|
||||
|
||||
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
|
||||
|
||||
## Visual tuning — keep markers unmistakable but not dominant.
|
||||
const _MAST_HEIGHT: float = 2.5
|
||||
const _MAST_RADIUS: float = 0.18
|
||||
## Visual tuning — readable from the default isometric camera at spawn.
|
||||
const _DUMMY_CAPSULE_HEIGHT: float = 1.75
|
||||
const _DUMMY_CAPSULE_RADIUS: float = 0.38
|
||||
const _RING_RADIUS_SEGMENTS: int = 72
|
||||
const _RING_Y: float = 0.05
|
||||
const _RING_THICKNESS: float = 0.06
|
||||
const _RING_THICKNESS: float = 0.08
|
||||
const _LABEL_Y: float = 2.15
|
||||
|
||||
var _markers_by_id: Dictionary = {}
|
||||
var _locked_target_id: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -27,6 +29,11 @@ func _ready() -> void:
|
|||
_spawn_marker(id_variant as String)
|
||||
|
||||
|
||||
func set_locked_target(target_id: String) -> void:
|
||||
_locked_target_id = target_id.strip_edges()
|
||||
_apply_highlight_states()
|
||||
|
||||
|
||||
func _spawn_marker(target_id: String) -> void:
|
||||
var anchor: Vector3 = PrototypeTargetConstants.anchor_position(target_id)
|
||||
var radius: float = PrototypeTargetConstants.anchor_radius(target_id)
|
||||
|
|
@ -39,23 +46,60 @@ func _spawn_marker(target_id: String) -> void:
|
|||
group.position = anchor
|
||||
add_child(group)
|
||||
|
||||
var mast := MeshInstance3D.new()
|
||||
mast.name = "Mast"
|
||||
var mast_mesh := CylinderMesh.new()
|
||||
mast_mesh.top_radius = _MAST_RADIUS
|
||||
mast_mesh.bottom_radius = _MAST_RADIUS
|
||||
mast_mesh.height = _MAST_HEIGHT
|
||||
mast.mesh = mast_mesh
|
||||
# CylinderMesh is Y-centered at its origin; raise so the base sits on the anchor Y.
|
||||
mast.position = Vector3(0.0, _MAST_HEIGHT * 0.5, 0.0)
|
||||
mast.material_override = _make_unshaded_material(tint, 1.0)
|
||||
group.add_child(mast)
|
||||
var body := MeshInstance3D.new()
|
||||
body.name = "DummyBody"
|
||||
var capsule := CapsuleMesh.new()
|
||||
capsule.radius = _DUMMY_CAPSULE_RADIUS
|
||||
capsule.height = _DUMMY_CAPSULE_HEIGHT
|
||||
body.mesh = capsule
|
||||
body.position = Vector3(0.0, _DUMMY_CAPSULE_HEIGHT * 0.5, 0.0)
|
||||
var body_mat := _make_unshaded_material(tint, 0.88)
|
||||
body.material_override = body_mat
|
||||
group.add_child(body)
|
||||
|
||||
var ring: MeshInstance3D = _make_radius_ring(radius, tint)
|
||||
ring.name = "RadiusRing"
|
||||
ring.position = Vector3(0.0, _RING_Y - anchor.y, 0.0)
|
||||
group.add_child(ring)
|
||||
|
||||
var label := Label3D.new()
|
||||
label.name = "NameLabel"
|
||||
label.text = PrototypeTargetConstants.display_short_name(target_id)
|
||||
label.font_size = 24
|
||||
label.outline_size = 6
|
||||
label.modulate = tint.lightened(0.15)
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.position = Vector3(0.0, _LABEL_Y, 0.0)
|
||||
group.add_child(label)
|
||||
|
||||
_markers_by_id[target_id] = {
|
||||
"body_mat": body_mat,
|
||||
"ring_mat": ring.material_override,
|
||||
"tint": tint,
|
||||
}
|
||||
|
||||
|
||||
func _apply_highlight_states() -> void:
|
||||
for id_variant: Variant in _markers_by_id.keys():
|
||||
if not id_variant is String:
|
||||
continue
|
||||
var target_id: String = id_variant as String
|
||||
var entry: Dictionary = _markers_by_id[target_id]
|
||||
var tint: Color = entry["tint"]
|
||||
var body_mat: StandardMaterial3D = entry["body_mat"]
|
||||
var ring_mat: StandardMaterial3D = entry["ring_mat"]
|
||||
var highlighted: bool = target_id == _locked_target_id and not _locked_target_id.is_empty()
|
||||
if highlighted:
|
||||
body_mat.albedo_color = tint.lightened(0.25)
|
||||
var ring_c := tint
|
||||
ring_c.a = 0.78
|
||||
ring_mat.albedo_color = ring_c
|
||||
else:
|
||||
body_mat.albedo_color = Color(tint.r, tint.g, tint.b, 0.88)
|
||||
var ring_c2 := tint
|
||||
ring_c2.a = 0.45
|
||||
ring_mat.albedo_color = ring_c2
|
||||
|
||||
|
||||
func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
|
|
@ -68,9 +112,6 @@ func _make_unshaded_material(tint: Color, alpha: float) -> StandardMaterial3D:
|
|||
return m
|
||||
|
||||
|
||||
## Builds a thin flat ring (annulus) by triangulating the band between `radius - t/2`
|
||||
## and `radius + t/2`. `ImmediateMesh` with `SurfaceTool` would also work, but an
|
||||
## `ArrayMesh` keeps this a static resource with no per-frame cost.
|
||||
func _make_radius_ring(radius: float, tint: Color) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
var arr_mesh := ArrayMesh.new()
|
||||
|
|
|
|||
|
|
@ -153,7 +153,8 @@ func test_cast_result_received_emits_false_with_reason_on_denied() -> void:
|
|||
var got: Array = []
|
||||
c.connect(
|
||||
"cast_result_received",
|
||||
func(accepted: bool, reason: String) -> void: got.append([accepted, reason])
|
||||
func(accepted: bool, reason: String, resolution: Dictionary) -> void:
|
||||
got.append([accepted, reason, resolution])
|
||||
)
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha"))
|
||||
|
|
@ -162,6 +163,7 @@ func test_cast_result_received_emits_false_with_reason_on_denied() -> void:
|
|||
assert_that(got.size()).is_equal(1)
|
||||
assert_that(bool(got[0][0])).is_false()
|
||||
assert_that(str(got[0][1])).is_equal("invalid_target")
|
||||
assert_that((got[0][2] as Dictionary).is_empty()).is_true()
|
||||
|
||||
|
||||
func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> void:
|
||||
|
|
@ -172,7 +174,8 @@ func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> voi
|
|||
var got: Array = []
|
||||
c.connect(
|
||||
"cast_result_received",
|
||||
func(accepted: bool, reason: String) -> void: got.append([accepted, reason])
|
||||
func(accepted: bool, reason: String, resolution: Dictionary) -> void:
|
||||
got.append([accepted, reason, resolution])
|
||||
)
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", null))
|
||||
|
|
@ -181,3 +184,52 @@ func test_cast_result_received_emits_true_with_empty_reason_on_accepted() -> voi
|
|||
assert_that(got.size()).is_equal(1)
|
||||
assert_that(bool(got[0][0])).is_true()
|
||||
assert_that(str(got[0][1])).is_equal("")
|
||||
assert_that((got[0][2] as Dictionary).is_empty()).is_true()
|
||||
|
||||
|
||||
func test_cast_result_received_emits_combat_resolution_on_accept() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.enqueue(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
200,
|
||||
(
|
||||
'{"schemaVersion":1,"accepted":true,"combatResolution":'
|
||||
+ '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",'
|
||||
+ '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}'
|
||||
)
|
||||
)
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect(
|
||||
"cast_result_received",
|
||||
func(accepted: bool, reason: String, resolution: Dictionary) -> void:
|
||||
got.append([accepted, reason, resolution])
|
||||
)
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha"))
|
||||
# Assert
|
||||
assert_that(started).is_true()
|
||||
assert_that(got.size()).is_equal(1)
|
||||
assert_that(bool(got[0][0])).is_true()
|
||||
var resolution: Dictionary = got[0][2]
|
||||
assert_that(int(resolution.get("damageDealt", 0))).is_equal(25)
|
||||
assert_that(int(resolution.get("targetRemainingHp", 0))).is_equal(75)
|
||||
assert_that(bool(resolution.get("targetDefeated", false))).is_false()
|
||||
|
||||
|
||||
func test_parse_cast_response_json_maps_combat_resolution() -> void:
|
||||
# Arrange
|
||||
var body := (
|
||||
'{"schemaVersion":1,"accepted":true,"combatResolution":'
|
||||
+ '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",'
|
||||
+ '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}'
|
||||
)
|
||||
# Act
|
||||
var parsed: Variant = CastClient.parse_cast_response_json(body)
|
||||
# Assert
|
||||
assert_that(parsed is Dictionary).is_true()
|
||||
var data: Dictionary = parsed
|
||||
assert_that(bool(data.get("accepted", false))).is_true()
|
||||
var resolution: Dictionary = data.get("resolution", {}) as Dictionary
|
||||
assert_that(int(resolution.get("damageDealt", 0))).is_equal(25)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-85: cast accept + target lock change trigger combat-target GET refresh.
|
||||
|
||||
const CastClient := preload("res://scripts/ability_cast_client.gd")
|
||||
const CombatTargetsClient := preload("res://scripts/combat_targets_client.gd")
|
||||
|
||||
|
||||
class MockCastTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var body_json: String = (
|
||||
'{"schemaVersion":1,"accepted":true,"combatResolution":'
|
||||
+ '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",'
|
||||
+ '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}'
|
||||
)
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class SpyCombatTargetsClient:
|
||||
extends Node
|
||||
var sync_calls: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_calls += 1
|
||||
|
||||
|
||||
class CombatRefreshHarness:
|
||||
extends Node
|
||||
var combat_sync_calls: int = 0
|
||||
var last_target_state: Dictionary = {}
|
||||
var _cast: Node
|
||||
var _combat: SpyCombatTargetsClient
|
||||
|
||||
func setup(parent: Node, cast_transport: Node) -> void:
|
||||
_combat = SpyCombatTargetsClient.new()
|
||||
_cast = CastClient.new()
|
||||
_cast.set("injected_http", cast_transport)
|
||||
parent.add_child(_cast)
|
||||
parent.add_child(_combat)
|
||||
_cast.connect("cast_result_received", Callable(self, "_on_cast_result"))
|
||||
last_target_state = {}
|
||||
|
||||
func _on_cast_result(accepted: bool, _reason_code: String, _resolution: Dictionary) -> void:
|
||||
if not accepted:
|
||||
return
|
||||
_request_combat_targets_refresh()
|
||||
|
||||
func _request_combat_targets_refresh() -> void:
|
||||
combat_sync_calls += 1
|
||||
_combat.request_sync_from_server()
|
||||
|
||||
func on_target_state_changed(state: Dictionary) -> void:
|
||||
var prev_lock := _locked_target_id_from_state(last_target_state)
|
||||
last_target_state = state.duplicate()
|
||||
var new_lock := _locked_target_id_from_state(last_target_state)
|
||||
if new_lock != prev_lock:
|
||||
_request_combat_targets_refresh()
|
||||
|
||||
func _locked_target_id_from_state(state: Dictionary) -> String:
|
||||
var locked_variant: Variant = state.get("lockedTargetId", null)
|
||||
if locked_variant is String:
|
||||
return (locked_variant as String).strip_edges()
|
||||
return ""
|
||||
|
||||
func request_cast() -> void:
|
||||
_cast.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha")
|
||||
|
||||
|
||||
func test_successful_cast_triggers_combat_target_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
var harness := CombatRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._cast)
|
||||
# Act
|
||||
harness.request_cast()
|
||||
# Assert
|
||||
await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any())
|
||||
assert_that(harness.combat_sync_calls).is_equal(1)
|
||||
assert_that(harness._combat.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_target_lock_change_triggers_combat_target_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
var harness := CombatRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
# Act
|
||||
harness.on_target_state_changed(
|
||||
{"lockedTargetId": "prototype_target_alpha", "validity": "ok", "sequence": 1}
|
||||
)
|
||||
# Assert
|
||||
assert_that(harness.combat_sync_calls).is_equal(1)
|
||||
assert_that(harness._combat.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_target_validity_only_update_skips_combat_target_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
var harness := CombatRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
harness.on_target_state_changed(
|
||||
{"lockedTargetId": "prototype_target_alpha", "validity": "ok", "sequence": 1}
|
||||
)
|
||||
# Act
|
||||
harness.on_target_state_changed(
|
||||
{"lockedTargetId": "prototype_target_alpha", "validity": "out_of_range", "sequence": 2}
|
||||
)
|
||||
# Assert
|
||||
assert_that(harness.combat_sync_calls).is_equal(1)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bd24nxwx2fju6
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-85: `CombatTargetsClient` GET parse + target row lookup.
|
||||
|
||||
const CombatTargetsClient := preload("res://scripts/combat_targets_client.gd")
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _two_target_snapshot_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"targets":['
|
||||
+ '{"targetId":"prototype_target_alpha","maxHp":100,"currentHp":75,"defeated":false},'
|
||||
+ '{"targetId":"prototype_target_beta","maxHp":100,"currentHp":100,"defeated":false}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = CombatTargetsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_request_sync_gets_combat_targets_url() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _two_target_snapshot_json()
|
||||
var c := _make_client(transport)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(transport.last_url).contains("/game/world/combat-targets")
|
||||
|
||||
|
||||
func test_targets_received_emits_parsed_snapshot() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _two_target_snapshot_json()
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect("targets_received", func(snapshot: Dictionary) -> void: got.append(snapshot))
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(got.size()).is_equal(1)
|
||||
var snapshot: Dictionary = got[0]
|
||||
var targets: Variant = snapshot.get("targets", null)
|
||||
assert_that(targets is Array).is_true()
|
||||
assert_that((targets as Array).size()).is_equal(2)
|
||||
|
||||
|
||||
func test_target_row_returns_alpha_hp_fields() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _two_target_snapshot_json()
|
||||
var c := _make_client(transport)
|
||||
var got: Array = []
|
||||
c.connect("targets_received", func(snapshot: Dictionary) -> void: got.append(snapshot))
|
||||
c.call("request_sync_from_server")
|
||||
var snapshot: Dictionary = got[0]
|
||||
# Act
|
||||
var row: Dictionary = c.call("target_row", "prototype_target_alpha", snapshot) as Dictionary
|
||||
# Assert
|
||||
assert_that(int(row.get("currentHp", 0))).is_equal(75)
|
||||
assert_that(int(row.get("maxHp", 0))).is_equal(100)
|
||||
assert_that(bool(row.get("defeated", false))).is_false()
|
||||
|
||||
|
||||
func test_invalid_schema_emits_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = '{"schemaVersion":2,"targets":[]}'
|
||||
var c := _make_client(transport)
|
||||
var failed: Array = []
|
||||
c.connect("targets_sync_failed", func(reason: String) -> void: failed.append(reason))
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(failed.size()).is_equal(1)
|
||||
assert_that(str(failed[0])).contains("schemaVersion")
|
||||
|
||||
|
||||
func test_http_404_emits_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
transport.body_json = ""
|
||||
var c := _make_client(transport)
|
||||
var failed: Array = []
|
||||
c.connect("targets_sync_failed", func(reason: String) -> void: failed.append(reason))
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(failed.size()).is_equal(1)
|
||||
assert_that(str(failed[0])).is_equal("HTTP 404")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://beuhga2dm8lhc
|
||||
|
|
@ -72,7 +72,9 @@ func test_successful_craft_triggers_both_refreshes() -> void:
|
|||
# Act
|
||||
harness.post_craft("refine_scrap_standard")
|
||||
# Assert
|
||||
assert_signal(harness._craft).is_emitted("craft_result_received")
|
||||
await assert_signal(harness._craft).is_emitted(
|
||||
"craft_result_received", "refine_scrap_standard", any()
|
||||
)
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,9 @@ func test_allowed_resource_interact_triggers_both_refreshes() -> void:
|
|||
# Act
|
||||
harness.post_resource_alpha()
|
||||
# Assert
|
||||
assert_signal(harness._ix).is_emitted("interaction_result_received")
|
||||
await assert_signal(harness._ix).is_emitted(
|
||||
"interaction_result_received", "prototype_resource_node_alpha", true, ""
|
||||
)
|
||||
assert_that(harness._inventory.sync_calls).is_equal(1)
|
||||
assert_that(harness._skill.sync_calls).is_equal(1)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-86: defeat cast accept triggers gig-progression GET refresh only.
|
||||
|
||||
const CastClient := preload("res://scripts/ability_cast_client.gd")
|
||||
|
||||
|
||||
class MockCastTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class SpyGigProgressionClient:
|
||||
extends Node
|
||||
var sync_calls: int = 0
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
sync_calls += 1
|
||||
|
||||
|
||||
class GigRefreshHarness:
|
||||
extends Node
|
||||
var gig_sync_calls: int = 0
|
||||
var _cast: Node
|
||||
var _gig: SpyGigProgressionClient
|
||||
|
||||
func setup(parent: Node, cast_transport: Node) -> void:
|
||||
_gig = SpyGigProgressionClient.new()
|
||||
_cast = CastClient.new()
|
||||
_cast.set("injected_http", cast_transport)
|
||||
parent.add_child(_cast)
|
||||
parent.add_child(_gig)
|
||||
_cast.connect("cast_result_received", Callable(self, "_on_cast_result"))
|
||||
|
||||
func _on_cast_result(accepted: bool, _reason_code: String, resolution: Dictionary) -> void:
|
||||
if not accepted:
|
||||
return
|
||||
if bool(resolution.get("targetDefeated", false)):
|
||||
_request_gig_progression_refresh()
|
||||
|
||||
func _request_gig_progression_refresh() -> void:
|
||||
gig_sync_calls += 1
|
||||
_gig.request_sync_from_server()
|
||||
|
||||
func request_cast() -> void:
|
||||
_cast.call("request_cast", 0, "prototype_pulse", "prototype_target_alpha")
|
||||
|
||||
|
||||
func test_defeat_cast_triggers_gig_progression_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"accepted":true,"combatResolution":'
|
||||
+ '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",'
|
||||
+ '"damageDealt":25,"targetRemainingHp":0,"targetDefeated":true}}'
|
||||
)
|
||||
var harness := GigRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._cast)
|
||||
# Act
|
||||
harness.request_cast()
|
||||
# Assert
|
||||
await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any())
|
||||
assert_that(harness.gig_sync_calls).is_equal(1)
|
||||
assert_that(harness._gig.sync_calls).is_equal(1)
|
||||
|
||||
|
||||
func test_non_defeat_cast_skips_gig_progression_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
transport.body_json = (
|
||||
'{"schemaVersion":1,"accepted":true,"combatResolution":'
|
||||
+ '{"abilityId":"prototype_pulse","targetId":"prototype_target_alpha",'
|
||||
+ '"damageDealt":25,"targetRemainingHp":75,"targetDefeated":false}}'
|
||||
)
|
||||
var harness := GigRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._cast)
|
||||
# Act
|
||||
harness.request_cast()
|
||||
# Assert
|
||||
await assert_signal(harness._cast).is_emitted("cast_result_received", true, "", any())
|
||||
assert_that(harness.gig_sync_calls).is_equal(0)
|
||||
|
||||
|
||||
func test_denied_cast_skips_gig_progression_sync() -> void:
|
||||
# Arrange
|
||||
var transport := MockCastTransport.new()
|
||||
transport.body_json = ('{"schemaVersion":1,"accepted":false,"reasonCode":"target_defeated"}')
|
||||
var harness := GigRefreshHarness.new()
|
||||
auto_free(transport)
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, transport)
|
||||
monitor_signals(harness._cast)
|
||||
# Act
|
||||
harness.request_cast()
|
||||
# Assert
|
||||
await assert_signal(harness._cast).is_emitted(
|
||||
"cast_result_received", false, "target_defeated", any()
|
||||
)
|
||||
assert_that(harness.gig_sync_calls).is_equal(0)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo86gigrefresh1
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-86: `GigProgressionClient` GET parse + failure signals.
|
||||
|
||||
const GigProgressionClient := preload("res://scripts/gig_progression_client.gd")
|
||||
|
||||
var _progression_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_progression(snapshot: Dictionary) -> void:
|
||||
_progression_capture = snapshot
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _breach_progression_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","mainGigId":"breach",'
|
||||
+ '"gigs":[{"id":"breach","xp":25,"level":1}]}'
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = GigProgressionClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_progression_json_reads_breach_row() -> void:
|
||||
# Arrange
|
||||
var json := _breach_progression_json()
|
||||
# Act
|
||||
var snapshot: Variant = GigProgressionClient.parse_progression_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = GigProgressionClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("gig_row", "breach", snapshot as Dictionary)
|
||||
assert_that(int(row.get("xp", -1))).is_equal(25)
|
||||
assert_that(int(row.get("level", -1))).is_equal(1)
|
||||
|
||||
|
||||
func test_request_sync_emits_progression_received() -> void:
|
||||
# Arrange
|
||||
_progression_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _breach_progression_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("progression_received", Callable(self, "_capture_progression"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("progression_received", any())
|
||||
assert_that(_progression_capture.get("mainGigId", "")).is_equal("breach")
|
||||
assert_that(transport.last_url).contains("/gig-progression")
|
||||
|
||||
|
||||
func test_http_404_emits_progression_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_invalid_schema_emits_progression_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = '{"schemaVersion":2,"mainGigId":"breach","gigs":[]}'
|
||||
var c := _make_client(transport)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted(
|
||||
"progression_sync_failed", "non-JSON body or schemaVersion mismatch"
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo86gigprogtest1
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-85: dev hotbar bootstrap binds prototype_pulse to slot 0 when empty.
|
||||
|
||||
const HotbarState := preload("res://scripts/hotbar_state.gd")
|
||||
const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse"
|
||||
|
||||
|
||||
class SpyHotbarClient:
|
||||
extends Node
|
||||
var bind_calls: Array = []
|
||||
|
||||
func request_bind_slot(slot_index: int, ability_id: String) -> void:
|
||||
bind_calls.append([slot_index, ability_id])
|
||||
|
||||
|
||||
class BootstrapHarness:
|
||||
extends Node
|
||||
var bootstrap_attempted: bool = false
|
||||
var _hotbar_state: Node
|
||||
var _hotbar_client: SpyHotbarClient
|
||||
|
||||
func setup(parent: Node, slot0_ability: Variant) -> void:
|
||||
_hotbar_state = HotbarState.new()
|
||||
_hotbar_client = SpyHotbarClient.new()
|
||||
parent.add_child(_hotbar_state)
|
||||
parent.add_child(_hotbar_client)
|
||||
var rows: Array = []
|
||||
for i in range(8):
|
||||
var row: Dictionary = {"slotIndex": i}
|
||||
if i == 0 and slot0_ability is String:
|
||||
row["abilityId"] = slot0_ability
|
||||
rows.append(row)
|
||||
_hotbar_state.call("apply_slots", rows)
|
||||
|
||||
func maybe_dev_bind_pulse_slot0() -> void:
|
||||
if bootstrap_attempted:
|
||||
return
|
||||
if not is_instance_valid(_hotbar_state) or not is_instance_valid(_hotbar_client):
|
||||
return
|
||||
var slots: Array = _hotbar_state.call("slots_snapshot") as Array
|
||||
if slots.is_empty():
|
||||
return
|
||||
var slot0: Variant = slots[0]
|
||||
if slot0 is String and not (slot0 as String).strip_edges().is_empty():
|
||||
return
|
||||
bootstrap_attempted = true
|
||||
_hotbar_client.request_bind_slot(0, DEV_BOOTSTRAP_CAST_ABILITY_ID)
|
||||
|
||||
|
||||
func test_empty_slot0_triggers_pulse_bind_once() -> void:
|
||||
# Arrange
|
||||
var harness := BootstrapHarness.new()
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, null)
|
||||
# Act
|
||||
harness.maybe_dev_bind_pulse_slot0()
|
||||
harness.maybe_dev_bind_pulse_slot0()
|
||||
# Assert
|
||||
assert_that(harness._hotbar_client.bind_calls.size()).is_equal(1)
|
||||
assert_that(int(harness._hotbar_client.bind_calls[0][0])).is_equal(0)
|
||||
assert_that(str(harness._hotbar_client.bind_calls[0][1])).is_equal(
|
||||
DEV_BOOTSTRAP_CAST_ABILITY_ID
|
||||
)
|
||||
|
||||
|
||||
func test_slot0_already_bound_skips_bootstrap() -> void:
|
||||
# Arrange
|
||||
var harness := BootstrapHarness.new()
|
||||
auto_free(harness)
|
||||
add_child(harness)
|
||||
harness.setup(self, "prototype_pulse")
|
||||
# Act
|
||||
harness.maybe_dev_bind_pulse_slot0()
|
||||
# Assert
|
||||
assert_that(harness._hotbar_client.bind_calls.size()).is_equal(0)
|
||||
assert_that(harness.bootstrap_attempted).is_false()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo85hotbarboot01
|
||||
|
|
@ -156,7 +156,7 @@ func test_denied_update_emits_reason_signal() -> void:
|
|||
# Act
|
||||
c.call("request_bind_slot", 1, "not_real")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("loadout_update_denied")
|
||||
await assert_signal(c).is_emitted("loadout_update_denied", "unknown_ability", any())
|
||||
|
||||
|
||||
func test_bind_slot_while_busy_is_ignored() -> void:
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ func test_interaction_result_emits_allow_signal() -> void:
|
|||
# Act
|
||||
ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
await assert_signal(ix).is_emitted(
|
||||
"interaction_result_received", "prototype_resource_node_alpha", true, ""
|
||||
)
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ func test_interaction_result_emits_deny_signal() -> void:
|
|||
# Act
|
||||
ix.call("post_interact_id", "prototype_urban_bulk_delta")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
await assert_signal(ix).is_emitted(
|
||||
"interaction_result_received", "prototype_urban_bulk_delta", false, "node_depleted"
|
||||
)
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ func test_interaction_request_failed_emits_on_http_error() -> void:
|
|||
# Act
|
||||
ix.call("post_interact_id", "prototype_resource_node_alpha")
|
||||
# Assert
|
||||
assert_signal(ix).is_emitted(
|
||||
await assert_signal(ix).is_emitted(
|
||||
"interaction_request_failed", "prototype_resource_node_alpha", "HTTP 500"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ func test_request_sync_emits_inventory_received() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_received")
|
||||
await assert_signal(c).is_emitted("inventory_received", any())
|
||||
assert_that(_inventory_capture.has("bagSlots")).is_true()
|
||||
assert_that((_inventory_capture["bagSlots"] as Array).size()).is_equal(24)
|
||||
assert_that((_inventory_capture["equipmentSlots"] as Array).size()).is_equal(1)
|
||||
|
|
@ -98,7 +98,7 @@ func test_http_404_emits_inventory_sync_failed() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_sync_failed", "HTTP 404 (player unknown)")
|
||||
await assert_signal(c).is_emitted("inventory_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_malformed_json_emits_inventory_sync_failed() -> void:
|
||||
|
|
@ -110,4 +110,4 @@ func test_malformed_json_emits_inventory_sync_failed() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("inventory_sync_failed")
|
||||
await assert_signal(c).is_emitted("inventory_sync_failed", any())
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func test_request_sync_hits_definitions_endpoint_and_emits() -> void:
|
|||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(transport.last_url).contains("/game/world/item-definitions")
|
||||
assert_signal(c).is_emitted("definitions_ready")
|
||||
await assert_signal(c).is_emitted("definitions_ready", any())
|
||||
|
||||
|
||||
func test_display_name_for_after_request_sync() -> void:
|
||||
|
|
@ -107,4 +107,4 @@ func test_http_error_does_not_emit_definitions_ready() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).wait_until(400).is_not_emitted("definitions_ready")
|
||||
await assert_signal(c).wait_until(400).is_not_emitted("definitions_ready")
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ func test_sync_boot_get_200_emits_snap() -> void:
|
|||
# Act
|
||||
c.sync_from_server()
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, -5.0), true)
|
||||
await assert_signal(c).is_emitted(
|
||||
"authoritative_position_received", Vector3(1.0, 0.9, -5.0), true
|
||||
)
|
||||
|
||||
|
||||
# NEO-24: boot sync also emits the cooldown-throttled heartbeat.
|
||||
|
|
@ -78,7 +80,7 @@ func test_sync_boot_get_200_emits_authoritative_ack() -> void:
|
|||
# Act
|
||||
c.sync_from_server()
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("authoritative_ack", Vector3(1.0, 0.9, -5.0))
|
||||
await assert_signal(c).is_emitted("authoritative_ack", Vector3(1.0, 0.9, -5.0))
|
||||
|
||||
|
||||
func test_sync_boot_get_200_malformed_position_does_not_emit() -> void:
|
||||
|
|
@ -90,7 +92,7 @@ func test_sync_boot_get_200_malformed_position_does_not_emit() -> void:
|
|||
# Act
|
||||
c.sync_from_server()
|
||||
# Assert
|
||||
assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
|
||||
await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
|
||||
|
||||
|
||||
func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void:
|
||||
|
|
@ -103,8 +105,10 @@ func test_move_stream_post_400_emits_move_rejected_then_boot_get() -> void:
|
|||
# Act
|
||||
c.submit_stream_targets([Vector3(1.0, 2.0, 3.0)])
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
|
||||
assert_signal(c).is_emitted("authoritative_position_received", Vector3(0.0, 0.9, 0.0), true)
|
||||
await assert_signal(c).is_emitted("move_rejected", "vertical_step_exceeded")
|
||||
await assert_signal(c).is_emitted(
|
||||
"authoritative_position_received", Vector3(0.0, 0.9, 0.0), true
|
||||
)
|
||||
|
||||
|
||||
func test_move_stream_post_400_without_reason_emits_unknown() -> void:
|
||||
|
|
@ -117,8 +121,10 @@ func test_move_stream_post_400_without_reason_emits_unknown() -> void:
|
|||
# Act
|
||||
c.submit_stream_targets([Vector3.ZERO])
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("move_rejected", "unknown")
|
||||
assert_signal(c).is_emitted("authoritative_position_received", Vector3(1.0, 0.9, 1.0), true)
|
||||
await assert_signal(c).is_emitted("move_rejected", "unknown")
|
||||
await assert_signal(c).is_emitted(
|
||||
"authoritative_position_received", Vector3(1.0, 0.9, 1.0), true
|
||||
)
|
||||
|
||||
|
||||
func test_second_sync_while_busy_is_ignored() -> void:
|
||||
|
|
@ -148,7 +154,7 @@ func test_move_stream_post_200_does_not_emit_position() -> void:
|
|||
# Act
|
||||
c.submit_stream_targets([Vector3(-4.9, 0.9, -5.0)])
|
||||
# Assert
|
||||
assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
|
||||
await assert_signal(c).wait_until(400).is_not_emitted("authoritative_position_received")
|
||||
|
||||
|
||||
# NEO-24: move-stream 200 must emit `authoritative_ack` so a held target lock can re-validate
|
||||
|
|
@ -169,4 +175,4 @@ func test_move_stream_post_200_emits_authoritative_ack() -> void:
|
|||
# Act
|
||||
c.submit_stream_targets([Vector3(-4.9, 0.9, -5.0)])
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("authoritative_ack", Vector3(-4.9, 0.9, -5.0))
|
||||
await assert_signal(c).is_emitted("authoritative_ack", Vector3(-4.9, 0.9, -5.0))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-85: prototype target display helpers for combat dummy markers and HUD copy.
|
||||
|
||||
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
|
||||
|
||||
|
||||
func test_display_short_name_maps_alpha_and_beta() -> void:
|
||||
# Arrange
|
||||
# Act
|
||||
var alpha: String = PrototypeTargetConstants.display_short_name("prototype_target_alpha")
|
||||
var beta: String = PrototypeTargetConstants.display_short_name("prototype_target_beta")
|
||||
# Assert
|
||||
assert_that(alpha).is_equal("Dummy α")
|
||||
assert_that(beta).is_equal("Dummy β")
|
||||
|
||||
|
||||
func test_display_short_name_falls_back_to_id() -> void:
|
||||
# Arrange
|
||||
# Act
|
||||
var unknown: String = PrototypeTargetConstants.display_short_name("unknown_target")
|
||||
# Assert
|
||||
assert_that(unknown).is_equal("unknown_target")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo85prototgt01
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-85: lock highlight uses albedo-only tuning on unshaded dummy materials.
|
||||
|
||||
const Markers := preload("res://scripts/prototype_target_markers.gd")
|
||||
|
||||
const ALPHA_ID := "prototype_target_alpha"
|
||||
|
||||
|
||||
func test_set_locked_target_brightens_body_albedo_without_emission() -> void:
|
||||
# Arrange
|
||||
var markers: Node3D = Markers.new()
|
||||
auto_free(markers)
|
||||
add_child(markers)
|
||||
await get_tree().process_frame
|
||||
var body: MeshInstance3D = markers.get_node("Marker_%s/DummyBody" % ALPHA_ID) as MeshInstance3D
|
||||
var body_mat: StandardMaterial3D = body.material_override as StandardMaterial3D
|
||||
var idle_color: Color = body_mat.albedo_color
|
||||
# Act
|
||||
markers.set_locked_target(ALPHA_ID)
|
||||
# Assert
|
||||
assert_that(body_mat.albedo_color).is_not_equal(idle_color)
|
||||
assert_that(body_mat.emission_enabled).is_false()
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://cneo85ptgtmarkers1
|
||||
|
|
@ -80,7 +80,7 @@ func test_request_sync_emits_recipes_ready_in_order() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("recipes_ready")
|
||||
await assert_signal(c).is_emitted("recipes_ready", any())
|
||||
assert_that(_recipes_capture.size()).is_equal(2)
|
||||
assert_that(str(_recipes_capture[0].get("id", ""))).is_equal("make_armor_quick")
|
||||
assert_that(transport.last_url).contains("/recipe-definitions")
|
||||
|
|
@ -95,7 +95,7 @@ func test_schema_mismatch_emits_recipes_sync_failed() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("recipes_sync_failed")
|
||||
await assert_signal(c).is_emitted("recipes_sync_failed", any())
|
||||
|
||||
|
||||
func test_http_error_emits_recipes_sync_failed_with_status() -> void:
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ func test_request_sync_emits_progression_received() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("progression_received")
|
||||
await assert_signal(c).is_emitted("progression_received", any())
|
||||
assert_that(_progression_capture.has("skills")).is_true()
|
||||
assert_that(transport.last_url).contains("/skill-progression")
|
||||
|
||||
|
|
@ -94,4 +94,4 @@ func test_http_404_emits_progression_sync_failed() -> void:
|
|||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")
|
||||
await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ func test_sync_get_200_emits_state() -> void:
|
|||
# Act
|
||||
c.request_sync_from_server()
|
||||
# Assert
|
||||
assert_signal(c).is_emitted("target_state_changed")
|
||||
await assert_signal(c).is_emitted("target_state_changed", any())
|
||||
var state: Dictionary = c.cached_state()
|
||||
assert_that(state.get("validity", "")).is_equal("none")
|
||||
assert_that(state.get("lockedTargetId", "non-null-sentinel")).is_null()
|
||||
|
|
@ -111,7 +111,7 @@ func test_tab_from_no_lock_selects_first_id() -> void:
|
|||
assert_that(transport.last_url).contains("/target/select")
|
||||
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
|
||||
assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)
|
||||
assert_signal(c).is_emitted("target_state_changed")
|
||||
await assert_signal(c).is_emitted("target_state_changed", any())
|
||||
assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
| [`items/`](items/) | Item catalogs; each row matches [`schemas/item-def.schema.json`](schemas/item-def.schema.json) — **stable `id`**, **`stackMax`**, **`inventorySlotKind`** for [E3.M3](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) |
|
||||
| [`recipes/`](recipes/) | Recipe catalogs; each row matches [`schemas/recipe-def.schema.json`](schemas/recipe-def.schema.json) — **stable `id`**, **`recipeKind`**, **`requiredSkillId`**, inputs/outputs for [E3.M2](../docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) |
|
||||
| [`abilities/`](abilities/) | Combat ability catalogs; each row matches [`schemas/ability-def.schema.json`](schemas/ability-def.schema.json) — **stable `id`**, **`baseDamage`**, **`cooldownSeconds`** for [E5.M1](../docs/decomposition/modules/E5_M1_CombatRulesEngine.md) |
|
||||
| [`npc-behaviors/`](npc-behaviors/) | NPC behavior archetype catalogs; each row matches [`schemas/npc-behavior-def.schema.json`](schemas/npc-behavior-def.schema.json) — **stable `id`**, **`archetypeKind`**, aggro/leash radii, telegraph + attack timings for [E5.M2](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) |
|
||||
| [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) |
|
||||
|
||||
**Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring.
|
||||
|
|
@ -22,6 +23,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
|
||||
**Prototype E5 Slice 1 — abilities (NEO-76):** CI expects **exactly four** ability ids aligned to [E5.M1 ability freeze](../docs/decomposition/modules/E5_M1_CombatRulesEngine.md#prototype-slice-1-freeze-e5m1-01) — **`prototype_pulse`**, **`prototype_guard`**, **`prototype_dash`**, **`prototype_burst`**. **Do not rename** ability `id` after ship—change **`displayName`** only. See [E5M1-prototype-backlog.md](../docs/plans/E5M1-prototype-backlog.md) and [NEO-76 plan](../docs/plans/NEO-76-implementation-plan.md).
|
||||
|
||||
**Prototype E5 Slice 2 — NPC behaviors (NEO-87):** CI expects **exactly three** npc behavior ids aligned to [E5.M2 behavior freeze](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01) — **`prototype_melee_pressure`**, **`prototype_ranged_control`**, **`prototype_elite_mini_boss`**. Each row requires **`leashRadius` > `aggroRadius`**. **Do not rename** behavior `id` after ship—change **`displayName`** only. See [E5M2-prototype-backlog.md](../docs/plans/E5M2-prototype-backlog.md) and [NEO-87 plan](../docs/plans/NEO-87-implementation-plan.md).
|
||||
|
||||
**Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45).
|
||||
|
||||
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"npcBehaviors": [
|
||||
{
|
||||
"id": "prototype_melee_pressure",
|
||||
"displayName": "Melee Pressure",
|
||||
"archetypeKind": "melee_pressure",
|
||||
"maxHp": 100,
|
||||
"aggroRadius": 8.0,
|
||||
"leashRadius": 16.0,
|
||||
"telegraphWindupSeconds": 1.5,
|
||||
"attackDamage": 15,
|
||||
"attackCooldownSeconds": 3.0
|
||||
},
|
||||
{
|
||||
"id": "prototype_ranged_control",
|
||||
"displayName": "Ranged Control",
|
||||
"archetypeKind": "ranged_control",
|
||||
"maxHp": 80,
|
||||
"aggroRadius": 10.0,
|
||||
"leashRadius": 20.0,
|
||||
"telegraphWindupSeconds": 2.0,
|
||||
"attackDamage": 12,
|
||||
"attackCooldownSeconds": 4.0
|
||||
},
|
||||
{
|
||||
"id": "prototype_elite_mini_boss",
|
||||
"displayName": "Elite Mini-Boss",
|
||||
"archetypeKind": "elite_mini_boss",
|
||||
"maxHp": 200,
|
||||
"aggroRadius": 8.0,
|
||||
"leashRadius": 18.0,
|
||||
"telegraphWindupSeconds": 2.5,
|
||||
"attackDamage": 25,
|
||||
"attackCooldownSeconds": 5.0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/npc-behavior-def.json",
|
||||
"title": "NpcBehaviorDef",
|
||||
"description": "Single NPC behavior archetype row for catalogs (e.g. content/npc-behaviors/*_npc_behaviors.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"displayName",
|
||||
"archetypeKind",
|
||||
"maxHp",
|
||||
"aggroRadius",
|
||||
"leashRadius",
|
||||
"telegraphWindupSeconds",
|
||||
"attackDamage",
|
||||
"attackCooldownSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Immutable behavior key for NPC instance binding, aggro, telegraph scheduling, and combat resolution. Never change after content ships; add a new id if the archetype splits."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating NPC data."
|
||||
},
|
||||
"archetypeKind": {
|
||||
"type": "string",
|
||||
"description": "Prototype archetype grouping for HUD, telemetry, and state-machine routing.",
|
||||
"enum": ["melee_pressure", "ranged_control", "elite_mini_boss"]
|
||||
},
|
||||
"maxHp": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Maximum HP for NPC instances bound to this behavior def."
|
||||
},
|
||||
"aggroRadius": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Horizontal proximity radius for re-aggro after leash clear (XZ-only at runtime)."
|
||||
},
|
||||
"leashRadius": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Horizontal radius beyond which aggro holder clears when the player leaves (must exceed aggroRadius)."
|
||||
},
|
||||
"telegraphWindupSeconds": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Windup duration before NPC attack resolves after telegraph starts."
|
||||
},
|
||||
"attackDamage": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Deterministic damage dealt to players after telegraph resolves in prototype Slice 2."
|
||||
},
|
||||
"attackCooldownSeconds": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"description": "Seconds after last attack before the NPC may enter telegraph again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,8 @@ Deliver tab-target combat resolution, NPC behaviors and telegraphs, encounter te
|
|||
- Telegraph timing matches UI feedback; aggro rules deterministic.
|
||||
- Telemetry hooks: `telegraph_fired`, `npc_state_transition`.
|
||||
|
||||
**Linear backlog:** [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) — [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). Verify in **Godot**, not Bruno-only.
|
||||
|
||||
### Slice 3 - Encounters and loot routing
|
||||
|
||||
- Scope: E5.M3 wired to E3.M3 and E7.M2 for rewards and quest progress.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
|
|||
|
||||
**Ability catalogs ([NEO-76](https://linear.app/neon-sprawl/issue/NEO-76)):** the same script validates each row in `content/abilities/*_abilities.json` against [`content/schemas/ability-def.schema.json`](../../../content/schemas/ability-def.schema.json), rejects **duplicate `id`** across files, and (E5 Slice 1) enforces the **frozen four-ability** id set (`prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst`). Extend the script when additional catalogs and schemas ship.
|
||||
|
||||
**NPC behavior catalogs ([NEO-87](https://linear.app/neon-sprawl/issue/NEO-87)):** the same script validates each row in `content/npc-behaviors/*_npc_behaviors.json` against [`content/schemas/npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json), rejects **duplicate `id`** across files, requires **`leashRadius` > `aggroRadius`** per row, and (E5 Slice 2) enforces the **frozen three-behavior** id set (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`).
|
||||
|
||||
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.
|
||||
|
||||
## Source anchors
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@
|
|||
|
||||
**NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** for scripted **`mission_reward`** skill XP ([implementation plan](../../plans/NEO-43-implementation-plan.md)); manual QA **[`NEO-43.md`](../../manual-qa/NEO-43.md)**; full quest hand-in awaits **E7.M2**.
|
||||
|
||||
**Still backlog within E2.M2 / Slice 3:** **NEO-44** (gig XP from combat, Epic 5 — not skill XP via E2.M2). **NEO-42** end-to-end craft wiring awaits **E3.M2** success handler.
|
||||
**NEO-44 landed (E5.M1 / combat path, not E2.M2 skill grant):** combat defeat on **`AbilityCastApi`** accept when **`targetDefeated`** grants **25** gig XP to prototype main gig **`breach`** via **`CombatDefeatGigXpGrant`** + **`IPlayerGigProgressionStore`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); read model **`GET /game/players/{id}/gig-progression`**; manual QA **[`NEO-44.md`](../../manual-qa/NEO-44.md)**; **[server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44)**; module handoff [E5.M1](E5_M1_CombatRulesEngine.md).
|
||||
|
||||
**Still backlog within E2.M2 / Slice 3:** **NEO-42** end-to-end craft wiring awaits **E3.M2** success handler (prep helper landed).
|
||||
|
||||
**E2.M3:** [MasteryAndPerkUnlocks](E2_M3_MasteryAndPerkUnlocks.md) re-evaluates perk eligibility on skill **level-up** after grants ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)).
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
| **Module ID** | E5.M1 |
|
||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07 [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast POST + wire `combatResolution` landed; E5M1-08 [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET landed; E5M1-09+ pending (see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | Ready — 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 [NEO-84](https://linear.app/neon-sprawl/issue/NEO-84) combat telemetry hook sites landed; E5M1-10 [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat landed; E5M1-11 [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) client combat HUD landed; E5M1-12 [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) playable combat capstone landed — Epic 5 Slice 1 client complete (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
|
||||
|
||||
**Authority:** [Client vs server](client_server_authority.md#e5m1-combatrulesengine) — server validates intents and computes all outcomes; never trust client damage/healing/crit/death.
|
||||
|
||||
Core tab-target combat resolution: valid actions against a locked target, hit resolution, cooldown and resource timing, and threat state as needed for prototype encounters. Presents readable outcomes in fixed isometric view and feeds XP awards through [E2.M2](E2_M2_XpAwardAndLevelEngine.md).
|
||||
Core tab-target combat resolution: valid actions against a locked target, hit resolution, cooldown and resource timing, and threat state as needed for prototype encounters. Presents readable outcomes in fixed isometric view. **Combat defeat** awards **gig XP** via **`IPlayerGigProgressionStore`** ([NEO-44](../../plans/NEO-44-implementation-plan.md)) — **not** E2.M2 skill XP ([progression.md](../../game-design/progression.md)).
|
||||
|
||||
**PvP:** Single engine for PvE and PvP; player-target hostility **gates** on [E6.M1](E6_M1_PvPEligibilityAndFlagState.md). See [PvP and the combat engine](pvp_combat_integration.md).
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
|
|||
- Combat state machine for tab-target flow (attacks, abilities per prototype scope).
|
||||
- Validate and apply `CombatAction`; produce `CombatResolution` for UI and logs (include **PvP deny reasons** when player targets are blocked; see [pvp_combat_integration.md](pvp_combat_integration.md)).
|
||||
- Integrate with targeting/interaction from **E1.M3** (target lock, valid target checks).
|
||||
- Award combat XP via E2.M2 integration where specified in slices.
|
||||
- Award **gig XP** on combat defeat via dedicated gig progression store ([NEO-44](../../plans/NEO-44-implementation-plan.md)); do **not** route default encounter clears through E2.M2 skill XP.
|
||||
|
||||
## Key contracts
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
|
|||
## Module dependencies
|
||||
|
||||
- **E1.M3** — [InteractionAndTargetingLayer](E1_M3_InteractionAndTargetingLayer.md): `TargetState`, selection, and alignment with **E1.M4** ([AbilityInputScaffold](E1_M4_AbilityInputScaffold.md)) for `AbilityCastRequest` wiring.
|
||||
- **E2.M2** — [XpAwardAndLevelEngine](E2_M2_XpAwardAndLevelEngine.md): combat XP grants.
|
||||
- **E2.M2** — [XpAwardAndLevelEngine](E2_M2_XpAwardAndLevelEngine.md): **skill** XP for gather/craft/mission callers only; combat → gig XP stays on E5.M1 (NEO-44).
|
||||
|
||||
## Dependents (by design)
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ Core tab-target combat resolution: valid actions against a locked target, hit re
|
|||
- **E5.M3** — EncounterAndRewardTables.
|
||||
- **E1.M4** — AbilityInputScaffold sends `AbilityCastRequest` to combat.
|
||||
- **E7.M1** — QuestStateMachine for combat-related quest steps.
|
||||
- **E2.M2** — Invoked as integration caller for XP (not a structural child).
|
||||
- **E2.M2** — Skill XP integration caller for gather/craft/mission paths (not structural child; not combat gig XP).
|
||||
|
||||
## Related implementation slices
|
||||
|
||||
|
|
@ -60,6 +60,14 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 4–6 a
|
|||
|
||||
**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/`.
|
||||
|
||||
**E5M1-09 (NEO-84):** comment-only Slice 1 telemetry hook sites in **`CombatOperations.TryResolve`** — **`ability_used`**, **`enemy_defeat`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84).
|
||||
|
||||
**E5M1-10 (NEO-44):** **`CombatDefeatGigXpGrant`** on cast accept when **`targetDefeated`** — **25** gig XP to prototype main gig **`breach`** via **`IPlayerGigProgressionStore`** (not E2.M2 skill XP); **`GET /game/players/{id}/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression snapshot (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`.
|
||||
|
||||
**E5M1-11 (NEO-85):** client combat HUD — **`ability_cast_client.gd`** parses nested **`combatResolution`** on accept; **`combat_targets_client.gd`** + **`CombatTargetHpLabel`**; **`CastFeedbackLabel`** damage/defeat copy; event-driven **`GET /game/world/combat-targets`** refresh on cast accept and lock change ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85).
|
||||
|
||||
**E5M1-12 (NEO-86):** playable combat capstone — **`gig_progression_client.gd`** + **`GigXpLabel`**; defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). Epic 5 Slice 1 client capstone complete.
|
||||
|
||||
## Linear backlog (decomposed)
|
||||
|
||||
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md).
|
||||
|
|
|
|||
|
|
@ -7,27 +7,33 @@
|
|||
| **Module ID** | E5.M2 |
|
||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
|
||||
## Purpose
|
||||
|
||||
Enemy archetype behavior loops, aggro logic, and telegraph scheduling on top of [E5.M1 — CombatRulesEngine](E5_M1_CombatRulesEngine.md).
|
||||
**Authority:** [Client vs server](client_server_authority.md) — server owns NPC behavior state, aggro holders, telegraph timing, and NPC→player damage; client displays telegraphs and state from polled snapshots only.
|
||||
|
||||
Enemy archetype behavior loops, aggro logic, and telegraph scheduling on top of [E5.M1 — CombatRulesEngine](E5_M1_CombatRulesEngine.md). Replaces passive **`prototype_target_alpha` / `beta`** dummies with **three** fight-back NPC instances for the vision prototype minimum ([three archetypes](../../../neon_sprawl_vision.plan.md#prototype-content-minimums)).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Run `NpcBehaviorDef`; emit `TelegraphEvent`; apply `AggroRule` / threat integration with combat state.
|
||||
- Run `NpcBehaviorDef`; emit `TelegraphEvent`; apply `AggroRule` / **`ThreatState`** integration with combat state.
|
||||
- Lazy NPC tick advance on **`GET /game/world/npc-runtime-snapshot`** poll (prototype).
|
||||
- Session **`IPlayerCombatHealthStore`** for incoming NPC damage (no Postgres in Slice 2).
|
||||
|
||||
## Key contracts
|
||||
|
||||
| Contract | Role |
|
||||
|----------|------|
|
||||
| `NpcBehaviorDef` | Archetype scripts or state machines. |
|
||||
| `NpcBehaviorDef` | Archetype scripts or state machines (content JSON + schema). |
|
||||
| `TelegraphEvent` | Telegraph timing for UI and dodge windows. |
|
||||
| `AggroRule` | Target selection and leash behavior. |
|
||||
| `ThreatState` | Per-NPC aggro holder (`playerId` or empty). |
|
||||
|
||||
## Module dependencies
|
||||
|
||||
- **E5.M1** — CombatRulesEngine.
|
||||
- **E5.M1** — CombatRulesEngine (player → NPC damage, combat-target HP, cast funnel).
|
||||
|
||||
## Dependents (by design)
|
||||
|
||||
|
|
@ -36,9 +42,53 @@ Enemy archetype behavior loops, aggro logic, and telegraph scheduling on top of
|
|||
|
||||
## Related implementation slices
|
||||
|
||||
Epic 5 **Slice 2** — three archetypes; `telegraph_fired`, `npc_state_transition`.
|
||||
Epic 5 **Slice 2 — NPC archetypes and telegraphs**: three archetypes (melee pressure, ranged control, elite mini-boss); telemetry `telegraph_fired`, `npc_state_transition`. **HTTP read model (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/`; Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||
|
||||
## Linear backlog (decomposed)
|
||||
|
||||
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md).
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E5M2-01 | [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) |
|
||||
| E5M2-02 | [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) |
|
||||
| E5M2-03 | [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) |
|
||||
| E5M2-04 | [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) |
|
||||
| E5M2-05 | [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) |
|
||||
| E5M2-06 | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) |
|
||||
| E5M2-07 | [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) |
|
||||
| E5M2-08 | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) |
|
||||
| E5M2-09 | [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) |
|
||||
| E5M2-10 | [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) |
|
||||
| E5M2-11 | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) |
|
||||
| E5M2-12 | [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
|
||||
## Prototype Slice 2 freeze (E5M2-01)
|
||||
|
||||
The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.json` is **frozen** for behavior binding, aggro tuning, and telegraph timings until a deliberate migration issue expands the roster.
|
||||
|
||||
| `NpcBehaviorDef.id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` |
|
||||
|---------------------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------|
|
||||
| **`prototype_melee_pressure`** | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 |
|
||||
| **`prototype_ranged_control`** | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 |
|
||||
| **`prototype_elite_mini_boss`** | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 |
|
||||
|
||||
**NPC instance ids (E5M2-05):** **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** — each binds one behavior def + world anchor (replaces **`prototype_target_alpha` / `beta`**).
|
||||
|
||||
**Rules:** Do **not** rename frozen behavior **`id`** values without a migration issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) and server startup ([NEO-88](../../plans/NEO-88-implementation-plan.md)) enforce **exactly** these three behavior ids, duplicate-`id` rejection, and **`leashRadius` > `aggroRadius`** per row. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows. Catalog + CI plan: [NEO-87 implementation plan](../../plans/NEO-87-implementation-plan.md). **`INpcBehaviorDefinitionRegistry`** + DI **landed** ([NEO-89](../../plans/NEO-89-implementation-plan.md), E5M2-03) — game code and HTTP read models should inject the registry, not **`NpcBehaviorDefinitionCatalog`**. Read-only HTTP projection: **`GET /game/world/npc-behavior-definitions`** ([NEO-90](../../plans/NEO-90-implementation-plan.md), E5M2-04).
|
||||
|
||||
**Server load (NEO-88):** On host startup, `server/NeonSprawl.Server/Game/Npc/` loads `content/npc-behaviors/*_npc_behaviors.json` with the same validation gates as CI and **refuses to listen** when the catalog is invalid. Config and discovery: [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). Plan: [NEO-88 implementation plan](../../plans/NEO-88-implementation-plan.md).
|
||||
|
||||
**NPC behavior definition registry (NEO-89):** **`INpcBehaviorDefinitionRegistry`** in `server/NeonSprawl.Server/Game/Npc/` wraps the startup-loaded catalog for read-only lookup by stable behavior `id` (`TryGetDefinition`), trim/lowercase validation (`TryNormalizeKnown`), and ordered enumeration (`GetDefinitionsInIdOrder`). Stable prototype id constants: **`PrototypeNpcBehaviorRegistry`**. **NEO-91+** (instance registry, runtime tick) should inject the interface rather than `NpcBehaviorDefinitionCatalog`. Plan: [NEO-89 implementation plan](../../plans/NEO-89-implementation-plan.md).
|
||||
|
||||
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||
|
||||
## Risks and telemetry
|
||||
|
||||
- Telegraph desync: single **`npc-runtime-snapshot`** poll surface; no client windup math.
|
||||
- Aggro confusion: deterministic first-hit + leash rules; instrument `npc_state_transition` when E9.M1 lands.
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5, prototype enemy minimums.
|
||||
- [Module dependency register](module_dependency_register.md)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -34,7 +34,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
**E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). **NEO-33** (content + CI + docs) moves the register row to **In Progress**; **NEO-34+** cover server registry and HTTP. Update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when slices land.
|
||||
|
||||
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Slice 3 integration: [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)–[NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) landed; **NEO-44** (gig XP, Epic 5) remains backlog. Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
||||
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Slice 3 integration: [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)–[NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) landed. **NEO-44** (gig XP on combat defeat) landed under **E5.M1** — combat → gig XP only, not E2.M2 skill grants ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)). Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
|
||||
|
||||
**E2.M3 note:** Epic 2 Slice 4 — [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49); label **`E2.M3`**. **NEO-45 landed:** prototype **`salvage`** mastery catalog + schema + CI. **NEO-46 landed:** fail-fast server mastery catalog load. **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants. **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook in `PerkUnlockEngine.TryUnlockPerks`. **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** + Bruno `perk-state/`. Flagship track; see [E2_M3_MasteryAndPerkUnlocks.md](E2_M3_MasteryAndPerkUnlocks.md), [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md), and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E2.M3 row.
|
||||
|
||||
|
|
@ -71,12 +71,14 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E5.M1 | CombatRulesEngine | E1.M3, E2.M2 | CombatAction, CombatResolution, ThreatState | Prototype | In Progress |
|
||||
| E5.M2 | NpcAiAndBehaviorProfiles | E5.M1 | NpcBehaviorDef, TelegraphEvent, AggroRule | Prototype | Planned |
|
||||
| E5.M1 | CombatRulesEngine | E1.M3, E2.M2 | CombatAction, CombatResolution, ThreatState | Prototype | Ready |
|
||||
| E5.M2 | NpcAiAndBehaviorProfiles | E5.M1 | NpcBehaviorDef, TelegraphEvent, AggroRule | Prototype | In Progress |
|
||||
| E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned |
|
||||
| E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned |
|
||||
|
||||
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **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/`.
|
||||
**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/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). **Epic 5 Slice 1 complete — register row Ready.**
|
||||
|
||||
**E5.M2 note:** Epic 5 **Slice 2** 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-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
# Manual QA — NEO-44 (combat defeat → gig XP, not skill XP)
|
||||
|
||||
Reference: [implementation plan](../plans/NEO-44-implementation-plan.md), [NEO-82 plan](../plans/NEO-82-implementation-plan.md) (cast defeat spine), [progression.md](../game-design/progression.md).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Run `NeonSprawl.Server` from `server/NeonSprawl.Server` (`dotnet run`; default `http://localhost:5253`).
|
||||
- Player **`dev-local-1`** (default seed).
|
||||
- For a clean gig XP baseline on in-memory mode, restart the server before the defeat spine.
|
||||
|
||||
```bash
|
||||
BASE=http://localhost:5253
|
||||
ID=dev-local-1
|
||||
```
|
||||
|
||||
## Automated coverage
|
||||
|
||||
- `GigProgressionSnapshotApiTests`, `CombatDefeatGigXpGrantTests`, `AbilityCastApiTests.PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression`
|
||||
- Bruno: `bruno/neon-sprawl-server/gig-progression/`
|
||||
|
||||
## 1. Fresh gig snapshot
|
||||
|
||||
```bash
|
||||
curl -sS "${BASE}/game/players/${ID}/gig-progression"
|
||||
```
|
||||
|
||||
Expect **`schemaVersion` 1**, **`mainGigId` `"breach"`**, one gig row with **`xp` 0** and **`level` 1** (fresh in-memory server).
|
||||
|
||||
## 2. Baseline skill snapshot (control)
|
||||
|
||||
```bash
|
||||
curl -sS "${BASE}/game/players/${ID}/skill-progression" | jq '.skills[] | select(.id=="salvage" or .id=="refine")'
|
||||
```
|
||||
|
||||
Note **`salvage`** and **`refine`** **`xp`** values — combat defeat must not change them.
|
||||
|
||||
## 3. Defeat `prototype_target_alpha` (four pulses)
|
||||
|
||||
Move in range, bind pulse, lock alpha, cast four times with ~3.2s between casts (or run Bruno **`Get gig progression after defeat spine.bru`**).
|
||||
|
||||
Minimal curl sequence (slot 0):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "${BASE}/game/players/${ID}/hotbar-loadout" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"slots":[{"slotIndex":0,"abilityId":"prototype_pulse"}]}'
|
||||
|
||||
curl -sS -X POST "${BASE}/game/players/${ID}/target/select" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}'
|
||||
|
||||
# Repeat cast (respect 3s pulse cooldown between each):
|
||||
curl -sS -X POST "${BASE}/game/players/${ID}/ability-cast" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"slotIndex":0,"abilityId":"prototype_pulse","targetId":"prototype_target_alpha"}'
|
||||
```
|
||||
|
||||
Fourth accept should show **`targetDefeated": true`**. Fifth cast should deny **`target_defeated`**.
|
||||
|
||||
## 4. Verify gig XP granted once
|
||||
|
||||
```bash
|
||||
curl -sS "${BASE}/game/players/${ID}/gig-progression"
|
||||
```
|
||||
|
||||
Expect **`breach`** row **`xp`** increased by **25** from the pre-spine baseline (Postgres retains prior totals across the full Bruno collection).
|
||||
|
||||
## 5. Verify skill XP unchanged
|
||||
|
||||
Re-run step 2 — **`salvage`** and **`refine`** **`xp`** must match the pre-defeat baseline.
|
||||
|
||||
## Invariant
|
||||
|
||||
Combat encounters grant **gig XP on main gig only** ([progression.md](../game-design/progression.md)). Mission/scripted skill payouts use **`mission_reward`** via E2.M2 ([NEO-43](./NEO-43.md)) — not the combat defeat path.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# NEO-85 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-85 |
|
||||
| Title | E5M1-11: Client combat feedback + target HP HUD |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-85/e5m1-11-client-combat-feedback-target-hp-hud |
|
||||
| Plan | `docs/plans/NEO-85-implementation-plan.md` |
|
||||
|
||||
## 1) Server
|
||||
|
||||
- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
- [ ] `curl -s http://localhost:5253/health` returns JSON with `status: ok`.
|
||||
- [ ] Optional reset between runs (Development host): `curl -sS -X POST http://localhost:5253/game/__dev/combat-targets-fixture -H 'Content-Type: application/json' -d '{"schemaVersion":1}'` returns **200** when the dev fixture route is enabled.
|
||||
|
||||
## 2) Client — combat HUD
|
||||
|
||||
- [ ] Open Godot main scene with server running; after hotbar sync, slot **0** auto-binds to **`prototype_pulse`** when empty (NEO-85 dev bootstrap — same as Bruno `Post hotbar bind slot0 pulse`).
|
||||
- [ ] Locate combat dummies near spawn: **orange capsule "Dummy α"** at **`(-3, -3)`** (west of player) and **purple "Dummy β"** at **`(3, 3)`**; each has a flat lock ring on the ground. Walk toward the orange dummy if needed.
|
||||
- [ ] **Tab** to lock **`prototype_target_alpha`** — dummy **brightens**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_target_alpha 100/100`** (or **`…`** briefly until GET completes).
|
||||
- [ ] Press **1** (hotbar slot 1 → index 0): **`CastFeedbackLabel`** shows **`Cast: 25 dmg → 75 HP`** (not Output-only).
|
||||
- [ ] **`CombatTargetHpLabel`** updates to **`75/100`** after the cast (event-driven GET refresh).
|
||||
- [ ] Cast **three more** pulse hits (respect cooldown): feedback steps **`50`**, **`25`**, then **`Cast: 25 dmg → 0 HP — defeated!`** on the lethal hit; HP label shows **`0/100 (defeated)`**.
|
||||
- [ ] Press **1** again on the defeated dummy: **`CastFeedbackLabel`** shows **`ability_cast_denied: target_defeated`**; HP label remains defeated.
|
||||
- [ ] **Esc** to clear lock: **`CombatTargetHpLabel`** shows **`Target HP: — (no lock)`**.
|
||||
|
||||
## 3) Regression
|
||||
|
||||
- [ ] Cast without lock still shows **`ability_cast_denied: invalid_target`** on **`CastFeedbackLabel`** (NEO-28).
|
||||
- [ ] Cooldown HUD still refreshes after accepted casts (NEO-32).
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# NEO-86 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-86 |
|
||||
| Title | E5M1-12: Playable tab-target combat capstone (Godot) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-86/e5m1-12-playable-tab-target-combat-capstone-godot |
|
||||
| Plan | `docs/plans/NEO-86-implementation-plan.md` |
|
||||
| Branch | `NEO-86-playable-combat-capstone` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** dummy HP and gig XP reset.
|
||||
- **No Bruno/curl seeding** for this checklist — hotbar pulse bind is automatic (NEO-85 dev bootstrap).
|
||||
- NEO-85 combat HUD and NEO-44 gig progression GET landed on the same branch or `main`.
|
||||
|
||||
## Combat math
|
||||
|
||||
| Cast # | Expected `CastFeedbackLabel` | `CombatTargetHpLabel` |
|
||||
|--------|------------------------------|------------------------|
|
||||
| 1 | `Cast: 25 dmg → 75 HP` | `75/100` |
|
||||
| 2 | `Cast: 25 dmg → 50 HP` | `50/100` |
|
||||
| 3 | `Cast: 25 dmg → 25 HP` | `25/100` |
|
||||
| 4 | `Cast: 25 dmg → 0 HP — defeated!` | `0/100 (defeated)` |
|
||||
| 5 | `ability_cast_denied: target_defeated` | unchanged defeated |
|
||||
|
||||
Respect ~3s **`prototype_pulse`** cooldown between casts.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`Economy HUD`** toggle is **on**; **`GigXpLabel`** shows **`breach: L1 · 0 xp`** after boot hydrate.
|
||||
3. Walk to orange **Dummy α** west of spawn **`(-3, -3)`** (flat lock ring on ground).
|
||||
4. Press **Tab** to lock **`prototype_target_alpha`** — dummy **brightens**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_target_alpha 100/100`** (or **`…`** briefly until GET completes).
|
||||
5. Press **1** (hotbar slot 1 → index 0) four times with cooldown between hits. Verify cast feedback and HP label step per table above.
|
||||
6. After the lethal hit, **`GigXpLabel`** updates to **`breach: L1 · 25 xp`** (event-driven GET refresh).
|
||||
7. Press **1** again on the defeated dummy: **`CastFeedbackLabel`** shows **`ability_cast_denied: target_defeated`**; gig XP stays **25**.
|
||||
8. Press **Esc** to clear lock: **`CombatTargetHpLabel`** shows **`Target HP: — (no lock)`**; combat feedback labels remain readable.
|
||||
9. Uncheck **`Economy HUD`** — gig row hides with inventory/skills/recipes; check again — gig row repopulates.
|
||||
10. Regression: cast without lock still shows **`ability_cast_denied: invalid_target`** on **`CastFeedbackLabel`** (NEO-28).
|
||||
|
||||
## Notes
|
||||
|
||||
- Shared Postgres dev DB may retain prior gig XP totals — capstone baseline prefers **server restart** on in-memory mode.
|
||||
- Optional mid-session dummy-only reset (Development host): `POST /game/__dev/combat-targets-fixture` — see [NEO-85 manual QA](NEO-85.md).
|
||||
- Component-level combat HUD regression: [NEO-85 manual QA](NEO-85.md).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Steps 1–10 completable in one session without Bruno/curl.
|
||||
- [ ] **`prototype_target_alpha`** shows defeated state in HUD after step 5.
|
||||
- [ ] **`breach`** gig XP **25** visible in Godot after step 6.
|
||||
- [ ] No silent cast failures — every deny shows readable reason on **`CastFeedbackLabel`**.
|
||||
|
|
@ -287,8 +287,10 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook names match [epic_05 Slice 1](../decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp) vocabulary.
|
||||
- [ ] **`enemy_defeat`** site fires only on transition to defeated (not every overkill cast).
|
||||
- [x] Hook names match [epic_05 Slice 1](../decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp) vocabulary.
|
||||
- [x] **`enemy_defeat`** site fires only on transition to defeated (not every overkill cast).
|
||||
|
||||
**Landed ([NEO-84](https://linear.app/neon-sprawl/issue/NEO-84)):** comment-only **`ability_used`** / **`enemy_defeat`** hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84-implementation-plan.md](NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks](../../../server/README.md#combat-telemetry-hooks-neo-84).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -311,11 +313,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition.
|
||||
- [ ] Skill progression snapshot unchanged by combat defeat in tests.
|
||||
- [ ] Documented invariant matches [progression.md](../game-design/progression.md).
|
||||
- [x] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition.
|
||||
- [x] Skill progression snapshot unchanged by combat defeat in tests.
|
||||
- [x] Documented invariant matches [progression.md](../game-design/progression.md).
|
||||
|
||||
**Note:** Issue pre-dates this backlog; slug **E5M1-10** maps to existing **[NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)**.
|
||||
**Landed ([NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)):** **`IPlayerGigProgressionStore`** + **`CombatDefeatGigXpGrant`** on cast accept when **`targetDefeated`**; **`GET …/gig-progression`**; main gig **`breach`**, **25** XP per defeat; plan [NEO-44-implementation-plan.md](NEO-44-implementation-plan.md); manual QA [`NEO-44`](../manual-qa/NEO-44.md).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -338,9 +340,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player sees damage/de defeat feedback in HUD after digit-key cast (not Output-only).
|
||||
- [ ] Target HP label matches server snapshot after cast sequence.
|
||||
- [ ] Manual QA exercisable without Bruno.
|
||||
- [x] Player sees damage/defeat feedback in HUD after digit-key cast (not Output-only).
|
||||
- [x] Target HP label matches server snapshot after cast sequence.
|
||||
- [x] Manual QA exercisable without Bruno.
|
||||
|
||||
**Landed ([NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)):** **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`** + **`CombatTargetHpLabel`**; event-driven GET refresh; plan [NEO-85-implementation-plan.md](NEO-85-implementation-plan.md); manual QA [`NEO-85`](../manual-qa/NEO-85.md).
|
||||
|
||||
**Client counterpart:** this issue (**NEO-85**).
|
||||
|
||||
|
|
@ -362,9 +366,11 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes script with server + client; **`prototype_target_alpha`** shows defeated state in HUD.
|
||||
- [ ] Epic 5 Slice 1 AC: combat readable in fixed isometric camera; no silent cast failures.
|
||||
- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
|
||||
- [x] Human completes script with server + client; **`prototype_target_alpha`** shows defeated state in HUD.
|
||||
- [x] Epic 5 Slice 1 AC: combat readable in fixed isometric camera; no silent cast failures.
|
||||
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
|
||||
|
||||
**Landed ([NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)):** **`gig_progression_client.gd`** + **`GigXpLabel`**; defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../manual-qa/NEO-86.md); plan [NEO-86-implementation-plan.md](NEO-86-implementation-plan.md); `client/README.md` end-to-end combat loop section. Epic 5 Slice 1 client capstone complete.
|
||||
|
||||
**Client counterpart:** this issue (**NEO-86**).
|
||||
|
||||
|
|
@ -372,7 +378,7 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
|
|||
|
||||
## After this backlog
|
||||
|
||||
- **[E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md)** Slice 2 — decompose when E5.M1 lands (NPC archetypes + telegraphs + client telegraph UX).
|
||||
- **[E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md)** Slice 2 — decomposed in [E5M2-prototype-backlog.md](E5M2-prototype-backlog.md) (**NEO-87** → **NEO-98**).
|
||||
- **[E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md)** module **Ready** when cast path returns real **`CombatResolution`** and client HUD reflects it.
|
||||
- Track delivery in Linear; keep `blockedBy` synchronized if scope changes.
|
||||
- For each issue kickoff: `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,393 @@
|
|||
# E5.M2 — Prototype story backlog (NpcAiAndBehaviorProfiles)
|
||||
|
||||
Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../decomposition/epics/epic_05_pve_combat.md#slice-2---npc-archetypes-and-telegraphs)). Decomposition and contracts: [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md).
|
||||
|
||||
**Full-stack policy:** Epics deliver **playable game features**. Every **player-visible** story has a **`client`** Linear issue created in **this same decomposition pass** — not an undocumented follow-up. Reference: [E5M1 (paired server+client)](../plans/E5M1-prototype-backlog.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E5.M2`**; add **`server`** or **`client`** + **`Story`** as listed.
|
||||
|
||||
**Precursor (do not re-scope):** [E5.M1](../decomposition/modules/E5_M1_CombatRulesEngine.md) combat spine — ability catalog ([NEO-76](https://linear.app/neon-sprawl/issue/NEO-76)–[NEO-79](https://linear.app/neon-sprawl/issue/NEO-79)), cast + **`CombatResolution`** ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)), combat-targets GET ([NEO-83](https://linear.app/neon-sprawl/issue/NEO-83)), client HUD + capstone ([NEO-85](https://linear.app/neon-sprawl/issue/NEO-85), [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)). **`ThreatState`** / aggro was **stub-only** in Slice 1 — this module owns it.
|
||||
|
||||
**Upstream (must be landed):** [E5.M1](../decomposition/modules/E5_M1_CombatRulesEngine.md) **Ready**; [E1.M3](../decomposition/modules/E1_M3_InteractionAndTargetingLayer.md) targeting + tab cycle ([NEO-23](https://linear.app/neon-sprawl/issue/NEO-23)–[NEO-26](https://linear.app/neon-sprawl/issue/NEO-26)).
|
||||
|
||||
**Prototype NPC spine (frozen in E5M2-01):** **three** `NpcBehaviorDef` rows — ids **`prototype_melee_pressure`**, **`prototype_ranged_control`**, **`prototype_elite_mini_boss`** — one per vision archetype (melee pressure, ranged control, elite mini-boss). **Three** combat NPC **instance** ids replace E1.M3/E5.M1 dummies: **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** (each binds a behavior def + world anchor).
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
|
||||
|
||||
| Slug | Layer | Linear |
|
||||
|------|-------|--------|
|
||||
| E5M2-01 | server | [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) |
|
||||
| E5M2-02 | server | [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) |
|
||||
| E5M2-03 | server | [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) |
|
||||
| E5M2-04 | server | [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) |
|
||||
| E5M2-05 | server | [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) |
|
||||
| E5M2-06 | server | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) |
|
||||
| E5M2-07 | server | [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) |
|
||||
| E5M2-08 | server | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) |
|
||||
| E5M2-09 | server | [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) |
|
||||
| E5M2-10 | server | [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) |
|
||||
| E5M2-11 | client | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) |
|
||||
| E5M2-12 | client | [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
|
||||
**Dependency graph in Linear:** E5M2-02 blocked by E5M2-01. E5M2-03 blocked by E5M2-02. E5M2-04 blocked by E5M2-03. E5M2-05 blocked by E5M2-03. E5M2-06 blocked by E5M2-05. E5M2-07 blocked by E5M2-06 and E5M2-03. E5M2-08 blocked by E5M2-07. E5M2-09 blocked by E5M2-08. E5M2-10 blocked by E5M2-07 (may parallel E5M2-09). E5M2-11 blocked by E5M2-08 and E5M2-09. E5M2-12 blocked by E5M2-11.
|
||||
|
||||
**Board order:** estimates **1–12** matching slug order (E5M2-01 = 1 … E5M2-12 = 12). On the Epic 5 project board, sort by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Layer | Depends on |
|
||||
|-------|------|-------|------------|
|
||||
| 1 | **E5M2-01** | server | E5.M1 Ready |
|
||||
| 2 | **E5M2-02** | server | E5M2-01 |
|
||||
| 3 | **E5M2-03** | server | E5M2-02 |
|
||||
| 4 | **E5M2-04** | server | E5M2-03 |
|
||||
| 5 | **E5M2-05** | server | E5M2-03 |
|
||||
| 6 | **E5M2-06** | server | E5M2-05 |
|
||||
| 7 | **E5M2-07** | server | E5M2-06, E5M2-03 |
|
||||
| 8 | **E5M2-08** | server | E5M2-07 |
|
||||
| 9 | **E5M2-09** | server | E5M2-08 |
|
||||
| 10 | **E5M2-10** | server | E5M2-07 |
|
||||
| 11 | **E5M2-11** | client | E5M2-08, E5M2-09 |
|
||||
| 12 | **E5M2-12** | client | E5M2-11 |
|
||||
|
||||
**Downstream (separate modules):** [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) encounters + loot; [E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md) spawn ecology; [E1.M4](../decomposition/modules/E1_M4_AbilityInputScaffold.md) may consume telegraph deny hints later.
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Archetype count | **3** frozen behavior defs | Vision prototype minimum; epic Slice 2 |
|
||||
| Dummy migration | **Replace** `prototype_target_alpha` / `beta` with **3 NPC instance ids** | Vision asks for 3 enemy archetypes, not passive dummies + NPCs |
|
||||
| NPC instance ids | **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** | Stable prefix; each references a `behaviorDefId` |
|
||||
| Aggro trigger | **First damaging cast** on NPC + **proximity re-aggro** inside `aggroRadius` | Deterministic; no random target swaps |
|
||||
| Leash | **Return idle** when holder player leaves `leashRadius` | Simple anti-kite prototype rule |
|
||||
| ThreatState | **In-memory** per-NPC aggro holder (`playerId` or empty) | E5.M1 stub → real store here |
|
||||
| Player HP | **Session in-memory** **`IPlayerCombatHealthStore`** (100 default); **no Postgres** | NPC pressure readable in Godot without E5.M1 persistence scope |
|
||||
| NPC tick | **Lazy advance** on **`GET …/npc-runtime-snapshot`** poll (client ~1 Hz in combat pocket) | Mirrors cooldown snapshot poll ([NEO-32](https://linear.app/neon-sprawl/issue/NEO-32)) |
|
||||
| Telegraph wire | **`GET /game/world/npc-runtime-snapshot`** v1 — states + active **`TelegraphEvent`** rows | Single poll surface; no client-side windup math |
|
||||
| NPC attacks | **Deterministic** damage from `NpcBehaviorDef.attackDamage` after telegraph resolves | Matches E5.M1 no-RNG combat |
|
||||
| PvP | NPCs **never** acquire player targets blocked by E6 stub | [pvp_combat_integration.md](../decomposition/modules/pvp_combat_integration.md) |
|
||||
| World placement | Static **`PrototypeNpcRegistry`** (successor to target registry anchors) until [E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md) | Same static-registry pattern as E5.M1 dummies |
|
||||
| HUD fidelity | Prototype Labels / debug panels (not final art) | NEO-85 / NEO-97 precedent |
|
||||
|
||||
---
|
||||
|
||||
### E5M2-01 — Prototype NpcBehaviorDef catalog + schemas + CI
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **three** frozen NPC behavior archetypes before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/npc-behavior-def.schema.json`.
|
||||
- `content/npc-behaviors/prototype_npc_behaviors.json` with stable **`id`** values: **`prototype_melee_pressure`**, **`prototype_ranged_control`**, **`prototype_elite_mini_boss`**.
|
||||
- Fields: **`displayName`**, **`archetypeKind`** enum (`melee_pressure` / `ranged_control` / `elite_mini_boss`), **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**.
|
||||
- `scripts/validate_content.py`: schema validation, duplicate ids, exact three-id allowlist, positive numeric guards.
|
||||
- Designer note in [E5_M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, runtime engine, HTTP, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates NPC behavior JSON against schema.
|
||||
- [x] Exactly three prototype behavior ids; duplicate `id` fails CI.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
**Landed ([NEO-87](https://linear.app/neon-sprawl/issue/NEO-87)):** [`content/npc-behaviors/prototype_npc_behaviors.json`](../../content/npc-behaviors/prototype_npc_behaviors.json), [`npc-behavior-def.schema.json`](../../content/schemas/npc-behavior-def.schema.json), CI gates in [`validate_content.py`](../../scripts/validate_content.py); plan [NEO-87-implementation-plan.md](NEO-87-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E5M2-02 — Server NPC behavior catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of `content/npc-behaviors/*.json` with CI-parity validation.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Npc/` (path finalized in plan).
|
||||
- Fail-fast on malformed files, duplicate ids, allowlist mismatch with CI.
|
||||
- Unit tests (AAA) for loader happy path and failure modes.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Injectable registry interface (E5M2-03), HTTP projection, runtime tick.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Host fails startup on invalid NPC behavior JSON (mirror CI rules).
|
||||
- [x] Tests cover at least one happy path and one malformed catalog rejection.
|
||||
|
||||
**Landed ([NEO-88](https://linear.app/neon-sprawl/issue/NEO-88)):** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/`; plan [NEO-88-implementation-plan.md](NEO-88-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E5M2-03 — Injectable `INpcBehaviorDefinitionRegistry` + DI
|
||||
|
||||
**Goal:** Replace direct catalog access with injectable registry; wire into host DI.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `INpcBehaviorDefinitionRegistry` + implementation backed by E5M2-02 catalog.
|
||||
- `TryNormalizeKnown` / `GetDefinitionsInIdOrder` parity with ability registry patterns.
|
||||
- DI extension method; unit tests (AAA).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP routes, NPC runtime, client.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Registry resolves all three frozen behavior ids.
|
||||
- [x] Unknown id normalization fails closed.
|
||||
|
||||
**Landed ([NEO-89](https://linear.app/neon-sprawl/issue/NEO-89)):** `INpcBehaviorDefinitionRegistry` + `NpcBehaviorDefinitionRegistry` + `PrototypeNpcBehaviorRegistry` in `server/NeonSprawl.Server/Game/Npc/`; plan [NEO-89-implementation-plan.md](NEO-89-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
### E5M2-04 — GET `/game/world/npc-behavior-definitions`
|
||||
|
||||
**Goal:** Read-only world projection of behavior defs for client labels and Bruno smokes.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `NpcBehaviorDefinitionsWorldApi` + versioned DTOs in `Game/Npc/`.
|
||||
- Integration tests (AAA); Bruno folder `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||
- `server/README.md` route section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Runtime state, telegraphs, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] GET returns all three defs in ascending `id` order with `schemaVersion` **1**.
|
||||
- [x] Bruno happy GET passes in CI Bruno step.
|
||||
|
||||
**Landed ([NEO-90](https://linear.app/neon-sprawl/issue/NEO-90)):** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/`; plan [NEO-90-implementation-plan.md](NEO-90-implementation-plan.md); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||
|
||||
---
|
||||
|
||||
### E5M2-05 — Prototype NPC instance registry + combat-target migration
|
||||
|
||||
**Goal:** Replace passive alpha/beta dummies with **three** NPC combat instances wired into targeting + HP stores.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `PrototypeNpcRegistry` (or extend targeting registry with NPC metadata): instance id, `behaviorDefId`, world anchor, shared lock radius convention.
|
||||
- Migrate **`PrototypeTargetRegistry`** / **`ICombatEntityHealthStore`** gates from alpha/beta → three NPC ids; max HP from behavior def.
|
||||
- Update Bruno combat-targets / ability-cast / target-select smokes; server tests.
|
||||
- Document breaking id change + client constant migration requirement in plan.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Behavior tick, aggro, telegraphs (E5M2-06+).
|
||||
- Godot markers — **client counterpart [NEO-97](#)** updates markers in parallel story after server ids land (blockedBy E5M2-08 for HUD; markers can land in E5M2-11).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Cast + combat-targets GET operate on three NPC ids only (alpha/beta removed).
|
||||
- [ ] Each instance references a valid frozen `behaviorDefId`.
|
||||
- [ ] Bruno defeat spine passes for at least one archetype.
|
||||
|
||||
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — archetype markers + telegraph HUD (after runtime snapshot lands).
|
||||
|
||||
---
|
||||
|
||||
### E5M2-06 — AggroRule engine + `IThreatStateStore`
|
||||
|
||||
**Goal:** Deterministic aggro: NPC acquires holder on first damaging player cast; clears on leash break.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `IThreatStateStore` + in-memory implementation keyed by NPC instance id.
|
||||
- `AggroOperations.TryAcquire` / `TryClearOnLeash` using player **`PositionState`** + registry anchors.
|
||||
- Hook from **`CombatOperations.TryResolve`** on successful NPC damage (player → NPC).
|
||||
- Unit + integration tests (AAA).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- NPC attack loop, telegraphs, client HUD.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] First damaging cast on NPC sets aggro holder to casting player id.
|
||||
- [ ] Holder clears when player leaves `leashRadius` (deterministic distance check).
|
||||
- [ ] Re-aggro inside radius after clear follows same first-hit rule.
|
||||
|
||||
---
|
||||
|
||||
### E5M2-07 — NPC behavior state machine + lazy tick advance
|
||||
|
||||
**Goal:** Server-owned NPC states: **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`** with catalog-driven timings.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `NpcRuntimeOperations.AdvanceAll` (or per-id) called from snapshot GET handler with monotonic clock delta cap.
|
||||
- State transitions emit internal events for telemetry hooks (E5M2-10).
|
||||
- Unit tests with injected clock for deterministic transitions.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP DTO projection (E5M2-08), player damage (E5M2-09), Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Aggro'd NPC enters telegraph after `attackCooldownSeconds` elapses from last attack.
|
||||
- [ ] Windup duration matches `telegraphWindupSeconds` from behavior def.
|
||||
- [ ] Idle NPC with no aggro holder does not telegraph.
|
||||
|
||||
---
|
||||
|
||||
### E5M2-08 — TelegraphEvent snapshot GET + wire DTOs
|
||||
|
||||
**Goal:** **`GET /game/world/npc-runtime-snapshot`** returns authoritative NPC runtime rows + active telegraphs for client poll.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `NpcRuntimeSnapshotWorldApi` + DTOs: per-NPC **`state`**, **`aggroHolderPlayerId`**, active **`TelegraphEvent`** (`telegraphId`, `npcInstanceId`, `windupRemainingSeconds`, `archetypeKind`, …).
|
||||
- Handler invokes E5M2-07 lazy tick before read.
|
||||
- Integration tests (AAA); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot poll client (E5M2-11).
|
||||
- Player damage application (E5M2-09).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Snapshot reflects state transition within one poll generation after aggro.
|
||||
- [ ] Active telegraph row appears during windup with decreasing remaining time.
|
||||
- [ ] Bruno smoke: lock NPC → damage → poll until telegraph row present.
|
||||
|
||||
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — telegraph label + NPC state HUD driven by this GET.
|
||||
|
||||
---
|
||||
|
||||
### E5M2-09 — NPC attack resolve + session player combat HP
|
||||
|
||||
**Goal:** When telegraph completes, apply **`attackDamage`** to aggro holder via **`IPlayerCombatHealthStore`**; expose player HP on snapshot or dedicated GET.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `IPlayerCombatHealthStore` + in-memory implementation (session-only, lazy init 100 HP).
|
||||
- `NpcAttackOperations.TryResolveTelegraphComplete` invoked from tick advance.
|
||||
- Extend snapshot DTO (or add **`GET /game/players/{id}/combat-health`**) with **`currentHp`** / **`maxHp`** for client HUD.
|
||||
- Integration tests: telegraph complete → player HP decreases deterministically.
|
||||
- Reserved **`player_death`** hook site when HP hits 0 (comment-only, no respawn loop).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Postgres persistence, healing, player defensive abilities.
|
||||
- Godot HUD — **client counterpart [NEO-97](#)**.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Elite archetype telegraph → attack deals catalog damage to holder.
|
||||
- [ ] Player HP read model matches store after NPC attack.
|
||||
- [ ] No client-side damage math required for verification (Bruno or integration tests).
|
||||
|
||||
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — player HP + incoming telegraph/attack feedback labels.
|
||||
|
||||
---
|
||||
|
||||
### E5M2-10 — Slice 2 NPC telemetry hook sites
|
||||
|
||||
**Goal:** Comment-only **`telegraph_fired`** and **`npc_state_transition`** hook sites in NPC runtime path.
|
||||
|
||||
**In scope**
|
||||
|
||||
- TODO(E9.M1) markers at telegraph start and on state transition commit.
|
||||
- `server/README.md` pointer; module doc cross-link.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Production ingest, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook names match [epic_05 Slice 2](../decomposition/epics/epic_05_pve_combat.md#slice-2---npc-archetypes-and-telegraphs) vocabulary.
|
||||
- [ ] No runtime behavior change beyond comments.
|
||||
|
||||
---
|
||||
|
||||
### E5M2-11 — Client telegraph HUD + NPC archetype markers
|
||||
|
||||
**Goal:** Godot shows **server-owned** telegraph timing, NPC state, and archetype markers for all three instances.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `client/scripts/npc_runtime_client.gd` — poll **`GET /game/world/npc-runtime-snapshot`** (~1 Hz when combat HUD visible).
|
||||
- `client/scripts/npc_archetype_markers.gd` — replace alpha/beta dummy markers with three archetype capsules + labels.
|
||||
- HUD labels: **`TelegraphLabel`**, **`NpcStateLabel`**, **`PlayerCombatHpLabel`** under `UICanvas`.
|
||||
- Update `prototype_target_constants.gd` / tab cycle for three NPC ids (server parity).
|
||||
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
|
||||
- `docs/manual-qa/NEO-97.md` — **Godot** steps (server + client running).
|
||||
- `client/README.md` NPC / telegraph section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final VFX art; dodge mechanics (prototype shows telegraph only).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player-visible telegraph countdown matches server snapshot within one poll frame.
|
||||
- [ ] Tab cycle visits three NPC instances in documented order.
|
||||
- [ ] Manual QA checklist exercisable without Bruno.
|
||||
|
||||
**Client counterpart:** this issue (**NEO-97**).
|
||||
|
||||
---
|
||||
|
||||
### E5M2-12 — Playable NPC telegraph combat capstone (Godot)
|
||||
|
||||
**Goal:** Prove Epic 5 Slice 2 acceptance **in Godot**: aggro a melee NPC, observe telegraph, take damage, defeat all three archetypes — without Bruno.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `docs/manual-qa/NEO-98.md` single-session script covering three archetypes.
|
||||
- `client/README.md` end-to-end NPC combat loop section.
|
||||
- Update [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M2 row when complete.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Encounters/loot ([E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)), spawn ecology ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes script with server + client; telegraph timing readable before damage.
|
||||
- [ ] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA.
|
||||
- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
|
||||
|
||||
**Client counterpart:** this issue (**NEO-98**).
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- **[E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)** Slice 3 — decompose when E5.M2 lands (encounters + loot routing).
|
||||
- Track delivery in Linear; keep `blockedBy` synchronized if scope changes.
|
||||
- For each issue kickoff: `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
- Add `docs/manual-qa/{NEO-XX}.md` for player-visible **client** stories when implementation lands.
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
- [x] Every player-visible AC has a **`client`** Linear issue (**NEO-97**, **NEO-98**)
|
||||
- [x] No “optional follow-up” / “future client” without NEO-XX
|
||||
- [x] Capstone client issue exists for playable Godot verification (**NEO-98**)
|
||||
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) updated
|
||||
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E5.M2 note updated when issues are created
|
||||
|
||||
## Related docs
|
||||
|
||||
- [E5_M2_NpcAiAndBehaviorProfiles.md](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md)
|
||||
- [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md)
|
||||
- [epic_05_pve_combat.md](../decomposition/epics/epic_05_pve_combat.md)
|
||||
- [abilities.md](../game-design/abilities.md)
|
||||
- [pvp_combat_integration.md](../decomposition/modules/pvp_combat_integration.md)
|
||||
- [client_server_authority.md](../decomposition/modules/client_server_authority.md)
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
# NEO-44 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-44 |
|
||||
| **Title** | E5M1-10: Combat encounter awards gig XP only (no skill XP via E2.M2) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-44/e5m1-10-combat-encounter-awards-gig-xp-only-no-skill-xp-via-e2m2 |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-10** |
|
||||
| **Branch** | `NEO-44-gig-xp-combat-defeat` |
|
||||
| **Precursor** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast POST + `combatResolution.targetDefeated` (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) — player-scoped GET snapshot; [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) — activity grant helper + constants; **not** `SkillProgressionGrantOperations` |
|
||||
| **Blocks** | [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone optional gig visibility |
|
||||
| **Client counterpart** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) / [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) — Godot gig row optional (**out of scope** here; no client change) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Prototype main gig id** | Which gig id receives combat XP before loadout/hub swap exists? | Frozen **`breach`** — E5M1-10 backlog default; damage gig from [gigs.md](../game-design/gigs.md). | **User:** **`breach`**. |
|
||||
| **Gig XP per defeat** | How much XP on `targetDefeated` transition? | **25** — matches **`prototype_pulse`** `baseDamage`; one readable step per alpha defeat. | **User:** **25**. |
|
||||
| **Wire visibility** | GET route vs cast response block? | **`GET /game/players/{id}/gig-progression` only** — NEO-37 pattern; keeps gig path outside E2.M2. | **User:** GET only. |
|
||||
| **Persistence** | Postgres migration vs in-memory only? | **Postgres + in-memory split** — mirror **`IPlayerSkillProgressionStore`** / V003 (NEO-29-style). | **User:** Postgres + in-memory. |
|
||||
| **Level field** | Derive level from curve or fixed? | **`level: 1`** fixed, **`xp`** accumulates — no gig level-curve content yet (NEO-37 pre-NEO-39 pattern). | **Adopted** (implicit with GET-only + prototype scope). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** When combat resolution transitions a prototype target to defeated (`combatResolution.targetDefeated === true`), grant **gig XP** to the attacker's **main gig** (`breach`) via a dedicated gig progression store. **Do not** call **`SkillProgressionGrantOperations`** or mutate skill XP. Expose a read-only **`GET`** snapshot for verification.
|
||||
|
||||
**In scope (from Linear + [E5M1-10](E5M1-prototype-backlog.md#e5m1-10--gig-xp-on-combat-defeat-neo-44)):**
|
||||
|
||||
- Minimal **`IPlayerGigProgressionStore`** + in-memory + Postgres implementations (**`V007__player_gig_progression.sql`**).
|
||||
- **`CombatDefeatGigXpGrant`** (or equivalent) + **`GigProgressionConstants`** (`breach`, **25** XP per defeat).
|
||||
- Hook in **`AbilityCastApi`** after successful **`CombatOperations.TryResolve`** when **`result.TargetDefeated`** is true.
|
||||
- **`GET /game/players/{id}/gig-progression`** — versioned snapshot with prototype **`mainGigId`** + single row for **`breach`** (`xp`, `level: 1`).
|
||||
- Integration tests (AAA): defeat grants gig XP once; **`target_defeated`** re-hit does not; skill snapshot unchanged.
|
||||
- Bruno: gig-progression GET + extend defeat spine to assert gig XP after 4th pulse.
|
||||
- **`server/README.md`** gig progression + combat defeat invariant; **`docs/manual-qa/NEO-44.md`**.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Full nine-gig catalog, hub swap, sub-gig XP ([gigs.md](../game-design/gigs.md)).
|
||||
- Gig XP on cast response wire block (kickoff: GET only).
|
||||
- Data-driven gig level curves / content catalog.
|
||||
- Godot gig bar — **[NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)** optional.
|
||||
- **`POST …/gig-progression`** grant route (combat hook is the only prototype writer).
|
||||
- Mission / **`mission_reward`** skill XP — unchanged ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Combat awards **gig** XP (main gig **`breach`**); no default skill XP grant from encounters.
|
||||
- [x] Mission/scripted skill payouts remain on the **mission_reward** / reward-router path.
|
||||
- [x] Defeating **`prototype_target_alpha`** grants gig XP once per defeat transition.
|
||||
- [x] Skill progression snapshot unchanged by combat defeat in tests.
|
||||
- [x] Documented invariant matches [progression.md](../game-design/progression.md).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Constants (`GigProgressionConstants`)** — frozen prototype values:
|
||||
- **`PrototypeMainGigId`** = **`breach`**
|
||||
- **`CombatDefeatXpAmount`** = **25**
|
||||
- Document: all players implicitly main **`breach`** until loadout/hub swap lands.
|
||||
|
||||
2. **Store (`Game/Gigs/`)** — parallel to skill progression, keyed by **`(playerId, gigId)`**:
|
||||
- **`IPlayerGigProgressionStore`**: **`GetXpTotals(playerId)`**, **`TryApplyXpDelta(playerId, gigId, amount, out previous, out new)`**.
|
||||
- **`InMemoryPlayerGigProgressionStore`** — seeds dev player bucket (same pattern as **`InMemoryPlayerSkillProgressionStore`**).
|
||||
- **`PostgresPlayerGigProgressionStore`** + **`PostgresGigProgressionBootstrap`** — **`V007__player_gig_progression.sql`**; FK to **`player_position`** like V003.
|
||||
- **`GigProgressionServiceCollectionExtensions.AddGigProgressionStore`** — Postgres when connection string set, else in-memory.
|
||||
|
||||
3. **Grant helper (`CombatDefeatGigXpGrant`)** — static method invoked from cast accept path:
|
||||
- **`GrantOnCombatDefeat(playerId, gigStore)`** → **`TryApplyXpDelta(playerId, breach, 25, …)`**.
|
||||
- **No** dependency on **`ISkillDefinitionRegistry`**, **`IPlayerSkillProgressionStore`**, or **`SkillProgressionGrantOperations`**.
|
||||
|
||||
4. **Cast hook (`AbilityCastApi`)** — after combat success, before returning accept JSON:
|
||||
- If **`combatResult.TargetDefeated`**: call **`CombatDefeatGigXpGrant.GrantOnCombatDefeat(trimmedPlayerId, gigStore)`**.
|
||||
- Inject **`IPlayerGigProgressionStore`** alongside existing combat dependencies.
|
||||
- Cast response **unchanged** (no `gigXpGrant` block — kickoff decision).
|
||||
|
||||
5. **GET snapshot (`GigProgressionSnapshotApi`)** — **`GET /game/players/{id}/gig-progression`**:
|
||||
- **404** when player unknown to **`IPositionStateStore`** (NEO-37 gate).
|
||||
- Response **`schemaVersion` 1**, **`playerId`**, **`mainGigId`** (`breach`), **`gigs`**: array with one row **`{ id: "breach", xp, level: 1 }`** (level fixed until gig curves exist).
|
||||
- Wire in **`Program.cs`** via **`app.MapGigProgressionSnapshotApi()`**.
|
||||
|
||||
6. **Tests** — extend **`InMemoryWebApplicationFactory`** to replace **`IPlayerGigProgressionStore`** with in-memory implementation (same loop as skill store).
|
||||
|
||||
7. **Bruno (`bruno/neon-sprawl-server/gig-progression/`)**:
|
||||
- **`Get gig progression.bru`** — fresh server: **`xp` 0**, **`level` 1**, **`mainGigId` breach**.
|
||||
- **`Get gig progression after defeat spine.bru`** — pre-request: reuse ability-cast defeat spine (4 pulses on alpha); GET asserts **`breach.xp === 25`**; optional GET skill-progression asserts **`salvage`/`refine`** unchanged from pre-defeat baseline.
|
||||
|
||||
8. **Docs** — README subsection: combat → gig XP invariant; link [progression.md](../game-design/progression.md); contrast with **`MissionRewardSkillXpGrant`** and gather/craft **`activity`** paths. Reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-10 checkboxes and [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md) when landed.
|
||||
|
||||
### Expected outcomes (4th pulse defeats alpha)
|
||||
|
||||
| Step | Cast `accepted` | `targetDefeated` | Gig XP (`breach`) | Skill XP (`salvage`/`refine`) |
|
||||
|------|-----------------|------------------|-------------------|-------------------------------|
|
||||
| Pulses 1–3 | true | false | 0 | unchanged |
|
||||
| Pulse 4 | true | true | **+25 → 25** | unchanged |
|
||||
| Pulse 5 | false (`target_defeated`) | — | **25** (no delta) | unchanged |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionConstants.cs` | Frozen `breach` main gig + **25** combat defeat XP. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/IPlayerGigProgressionStore.cs` | Gig XP persistence contract. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/InMemoryPlayerGigProgressionStore.cs` | Dev/test in-memory store. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/PostgresPlayerGigProgressionStore.cs` | Postgres-backed store. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/PostgresGigProgressionBootstrap.cs` | Flyway DDL bootstrap for V007. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionServiceCollectionExtensions.cs` | DI registration (Postgres vs in-memory). |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/CombatDefeatGigXpGrant.cs` | Defeat transition grant helper. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotApi.cs` | `GET …/gig-progression` route. |
|
||||
| `server/NeonSprawl.Server/Game/Gigs/GigProgressionSnapshotDtos.cs` | Versioned response DTOs. |
|
||||
| `server/db/migrations/V007__player_gig_progression.sql` | Postgres DDL for gig XP totals. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs` | GET snapshot + 404 gate (AAA). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs` | Unit tests for grant helper. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs` | Optional Postgres round-trip (mirror NEO-38 pattern). |
|
||||
| `bruno/neon-sprawl-server/gig-progression/Get gig progression.bru` | Fresh snapshot smoke. |
|
||||
| `bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru` | Defeat → gig XP AC spine. |
|
||||
| `bruno/neon-sprawl-server/gig-progression/folder.bru` | Run order + cross-links. |
|
||||
| `docs/manual-qa/NEO-44.md` | Bruno/curl defeat → GET gig XP; skill snapshot unchanged. |
|
||||
| `docs/plans/NEO-44-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject gig store; call grant when `TargetDefeated` on combat success. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | `AddGigProgressionStore` + `MapGigProgressionSnapshotApi`. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Replace Postgres gig store with in-memory in tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Defeat chain: gig XP +25 on 4th pulse; 5th no delta; skill GET unchanged. |
|
||||
| `server/README.md` | Gig progression GET + combat defeat → gig XP invariant. |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | Mark E5M1-10 landed when complete. |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | E5M1-10 handoff paragraph + Linear row. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 / E2.M2 Slice 3 rows when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/CombatDefeatGigXpGrantTests.cs` | **Grant:** delta +25 on **`breach`**. **Idempotent store failure:** no throw when player missing. **AAA**. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionSnapshotApiTests.cs` | **GET** 404 unknown player; **GET** dev player **`xp` 0**, **`level` 1**, **`mainGigId` breach**. **AAA**. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | **Defeat chain:** after 4th pulse, **`GET …/gig-progression`** → **`breach.xp` 25**; **`GET …/skill-progression`** salvage/refine unchanged; 5th cast deny → gig XP still 25. **AAA**. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs` | **Optional:** Postgres store persists defeat grant (skip if no test DB — mirror skill persistence test guard). |
|
||||
|
||||
Bruno defeat spine + manual QA complement automated coverage.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Main gig assignment without loadout** | **Frozen `breach` for all players** (user kickoff). | **adopted** |
|
||||
| **Gig XP amount** | **25 per defeat** (user kickoff). | **adopted** |
|
||||
| **Cast response gig block** | **Defer** — GET only (user kickoff); NEO-85/86 may poll GET. | **adopted** |
|
||||
| **Level curve absence** | **`level: 1` fixed** until gig content issue. | **adopted** |
|
||||
| **Beta dummy defeat** | **Same grant path** for any prototype target defeat (alpha AC explicit; beta covered by shared hook). | **adopted** |
|
||||
| **Grant failure on defeat** | **Best-effort apply** — cast accept still returns combat resolution even if gig store write fails (log/monitor later); prototype in-memory path should not fail for known dev player. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- Prototype main gig **`breach`**; **25** gig XP per **`targetDefeated`** transition.
|
||||
- Dedicated **`GET /game/players/{id}/gig-progression`**; no cast wire extension.
|
||||
- Postgres + in-memory gig store (V007); **no** E2.M2 skill grant on combat defeat.
|
||||
- **`docs/manual-qa/NEO-44.md`** for server verification spine.
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`Game/Gigs/`** — **`IPlayerGigProgressionStore`**, in-memory + Postgres (V007), **`CombatDefeatGigXpGrant`**, **`GET …/gig-progression`**.
|
||||
- **`AbilityCastApi`** — best-effort gig XP grant when **`combatResult.TargetDefeated`** on accept path.
|
||||
- Tests: **`GigProgressionSnapshotApiTests`**, **`CombatDefeatGigXpGrantTests`**, extended **`AbilityCastApiTests`** defeat + skill unchanged; optional Postgres persistence test.
|
||||
- Bruno `gig-progression/`; **`server/README.md`**, E5M1 backlog, E5_M1 module, alignment register updated.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# NEO-84 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-84 |
|
||||
| **Title** | E5M1-09: Slice 1 combat telemetry hook sites |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-84/e5m1-09-slice-1-combat-telemetry-hook-sites |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-09** |
|
||||
| **Branch** | `NEO-84-combat-telemetry-hooks` |
|
||||
| **Blocked by** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast POST + `combatResolution` (**Done** on `main`); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) — engine-only comment hooks in `*Operations`; [NEO-30](https://linear.app/neon-sprawl/issue/NEO-30) — separate E1.M4 funnel names in `AbilityCastApi` (keep, do not duplicate E5 names there) |
|
||||
| **Client counterpart** | None — comment-only server story; combat HUD is [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-84.md`? | **Skip** — server-only; comments-only; no API/response change (NEO-71 / NEO-83 pattern). | **User:** skip. |
|
||||
| **Hook anchor** | Engine only vs engine + `AbilityCastApi`? | **`CombatOperations.TryResolve` only** — HTTP delegates to engine; NEO-30 stays for `ability_cast_*` funnel events (NEO-71/64/56 precedent). | **User:** engine only. |
|
||||
| **`ability_used` scope** | Zero-damage successful resolves (`prototype_guard`, `prototype_dash`)? | **Yes** — every successful `TryResolve` return (ability still consumed in combat path). | **Adopted** (backlog “cast accept path” + readable prototype semantics). |
|
||||
| **Runtime behavior** | Comments-only vs dev `ILogger`? | **Comments-only** + `TODO(E9.M1)` (NEO-40/71 precedent). | **Adopted** (repo precedent). |
|
||||
| **Reserved sites** | Where for `encounter_start` / `player_death`? | **Class-level** reserved hook block on `CombatOperations` — encounters (E5.M3) and player HP deferred per [E5M1 kickoff defaults](E5M1-prototype-backlog.md#kickoff-decisions-decomposition-defaults). | **Adopted** (backlog default). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Document and place **comment-only hook sites** on the server-authoritative combat resolve path for Epic 5 Slice 1 product telemetry names **`ability_used`** and **`enemy_defeat`**, plus **reserved** sites for **`encounter_start`** and **`player_death`**. No E9.M1 ingest.
|
||||
|
||||
**In scope (from Linear + [E5M1-09](E5M1-prototype-backlog.md#e5m1-09--slice-1-combat-telemetry-hook-sites)):**
|
||||
|
||||
- Hook placement in **`CombatOperations.TryResolve`** for **`ability_used`** (every successful resolve) and **`enemy_defeat`** (defeat transition only).
|
||||
- Reserved **`encounter_start`** / **`player_death`** comment sites (no executable path in Slice 1).
|
||||
- **`TODO(E9.M1)`** markers; **`server/README.md`** combat telemetry subsection; [E5_M1 module](../decomposition/modules/E5_M1_CombatRulesEngine.md) landed line.
|
||||
- E5M1 backlog + alignment register updates when implementation completes.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Production logging, dashboards, metrics (**E9.M1**).
|
||||
- `ILogger` / dev-only log lines.
|
||||
- Duplicate hook comments in **`AbilityCastApi`** (NEO-30 funnel hooks remain; E5 combat vocabulary lives in engine only).
|
||||
- Godot client — **[NEO-85](https://linear.app/neon-sprawl/issue/NEO-85)**.
|
||||
- **`docs/manual-qa/NEO-84.md`** (kickoff decision).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Hook names match [epic_05 Slice 1](../decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp) vocabulary (`ability_used`, `enemy_defeat`, reserved `encounter_start`, `player_death`).
|
||||
- [x] **`enemy_defeat`** site documents emit only on transition to defeated (not re-hit / overkill — enforced by `target_defeated` deny before damage).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Authoritative surface:** **`CombatOperations.TryResolve`** — all combat damage and defeat transitions funnel here; **`AbilityCastApi`** invokes after E1.M4 gates and commits cooldown / gig XP without duplicating E5 hook comments.
|
||||
|
||||
2. **Class summary + reserved sites (NEO-84):** Extend **`CombatOperations`** XML summary with NEO-84 telemetry anchor. Add a **reserved hook sites** comment block (no `TODO` emit) documenting:
|
||||
- **`encounter_start`** — future E5.M3 encounter spawn / engage path (not wired in Slice 1).
|
||||
- **`player_death`** — future player HP / death resolution (out of scope Slice 1 per backlog).
|
||||
|
||||
3. **Hook site A — `ability_used`:** On **every successful** return path (zero-damage branch and post-`TryApplyDamage` success):
|
||||
- Comment block names future E9.M1 event **`ability_used`**.
|
||||
- **`TODO(E9.M1): catalog emit`** (NEO-71 style).
|
||||
- Planned payload fields: `abilityId`, `targetId`, `damageDealt`, `targetRemainingHp`, `targetDefeated`; note **`playerId`** is not in engine scope — E9.M1 ingest at **`AbilityCastApi`** accept layer should correlate route `id` with this event (document in README, do not add API duplicate hook).
|
||||
|
||||
4. **Hook site B — `enemy_defeat`:** Immediately after **`TryApplyDamage`** when **`after.Defeated`** is **`true`** (lethal hit transition only):
|
||||
- Comment block names **`enemy_defeat`**.
|
||||
- **`TODO(E9.M1): catalog emit`** — once per target defeat transition.
|
||||
- Planned payload fields: `targetId`, `abilityId` (killing blow), `damageDealt`, final HP **0**; correlate `playerId` at cast layer as above.
|
||||
- **AC guarantee:** Re-hit on defeated targets returns **`target_defeated`** deny **before** damage — hook never reached on overkill.
|
||||
|
||||
5. **NEO-30 coexistence:** Keep existing **`ability_cast_requested`** / **`ability_cast_denied`** comments in **`AbilityCastApi`** (E1.M4 funnel). **`ability_used`** is the Epic 5 combat-outcome catalog name at resolve success — document both in README under separate subsections.
|
||||
|
||||
6. **NEO-44 / gig XP:** No new hooks in **`CombatDefeatGigXpGrant`** — defeat side effects stay separate from telemetry vocabulary; **`enemy_defeat`** anchor remains on the HP transition in **`TryResolve`**.
|
||||
|
||||
7. **Docs (minimal):**
|
||||
- **`server/README.md`:** new **NEO-84 combat telemetry hooks** subsection under combat engine (hook table + “no production ingest”).
|
||||
- **`CombatResult.cs`:** one-line summary cross-reference to NEO-84 hook anchor in **`CombatOperations`** (optional wording tweak).
|
||||
- **`E5_M1_CombatRulesEngine.md`:** E5M1-09 landed line with README link.
|
||||
- **`documentation_and_implementation_alignment.md`** + **`E5M1-prototype-backlog.md`:** E5M1-09 checkboxes / landed note when complete.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-84-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatOperations.cs` | **NEO-84:** reserved-site class comment; **`ability_used`** on both success paths; **`enemy_defeat`** on `after.Defeated` transition only. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatResult.cs` | Cross-reference NEO-84 hook anchor in type summary (wording tweak if needed). |
|
||||
| `server/README.md` | Document combat telemetry hook placement (NEO-84); distinguish from NEO-30 cast funnel hooks. |
|
||||
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | E5M1-09 telemetry hooks landed line + README link. |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | E5M1-09 acceptance checkboxes + landed note when complete. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row — E5M1-09 complete when landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| **None required** | Comments-only markers; existing **`CombatOperationsTests`** and **`AbilityCastApiTests`** cover resolve/defeat behavior unchanged. |
|
||||
|
||||
**Manual verification:** Code review that comment blocks cite **`ability_used`** / **`enemy_defeat`** / reserved names, include **`TODO(E9.M1)`**, and that **`enemy_defeat`** sits only on the post-damage defeat branch. Optional Bruno cast + combat-targets regression (no request/response change).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
**None.** Precursors NEO-82 and NEO-44 are complete; production logging risk avoided by **comments-only** kickoff decisions.
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
# NEO-85 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-85 |
|
||||
| **Title** | E5M1-11: Client combat feedback + target HP HUD |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-85/e5m1-11-client-combat-feedback-target-hp-hud |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-11** |
|
||||
| **Branch** | `NEO-85-client-combat-feedback-hp-hud` |
|
||||
| **Server deps** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast POST + nested **`combatResolution`** (**Done** on `main`); [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) — **`GET /game/world/combat-targets`** (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28) / [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — thin HTTP clients, `main.gd` HUD wiring, GdUnit mock transport |
|
||||
| **Downstream** | [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone playable tab-target combat loop (manual QA script) |
|
||||
| **Server counterpart** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) + [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) — this issue is the **client** half; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **HP refresh** | Event-driven vs periodic poll for **`GET /game/world/combat-targets`**? | **Event-driven only** — refresh after successful cast accept + on target lock change; matches cooldown sync pattern and minimizes HTTP chatter ([E5M1-11 backlog](E5M1-prototype-backlog.md#e5m1-11--client-combat-feedback--target-hp-hud)). | **User:** event-driven only. |
|
||||
| **Cast feedback surface** | Extend **`CastFeedbackLabel`** vs dedicated combat line? | **Extend `CastFeedbackLabel`** with resolution copy on accept — NEO-28 precedent; keep HUD compact. | **User:** extend `CastFeedbackLabel`. |
|
||||
| **HP label placement** | Dedicated label vs append to **`TargetLockLabel`**? | **New `CombatTargetHpLabel`** below **`CastFeedbackLabel`** — AC requires readable HP readout; **`TargetLockLabel`** is already dense lock/range debug. | **User:** dedicated `CombatTargetHpLabel`. |
|
||||
|
||||
No other blocking decisions — wire field names follow NEO-82 **`combatResolution`** / NEO-83 **`targets`** row vocabulary; zero-damage accepts show **`0 dmg`** from server fields (no local combat math).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Godot displays authoritative cast outcomes and locked dummy HP without local combat math — player-visible damage/defeat feedback and an HP label that matches the server snapshot after casts.
|
||||
|
||||
**In scope (from Linear + [E5M1-11](E5M1-prototype-backlog.md#e5m1-11--client-combat-feedback--target-hp-hud)):**
|
||||
|
||||
- Extend **`ability_cast_client.gd`** to parse nested **`combatResolution`** on accept and surface it on **`cast_result_received`**.
|
||||
- Update **`CastFeedbackLabel`** with damage / defeat messaging on accept (e.g. **`Cast: 25 dmg → 75 HP`**, **`Cast: target defeated!`** when **`targetDefeated`**).
|
||||
- **`combat_targets_client.gd`** — **`GET /game/world/combat-targets`**; cache snapshot; **`target_row(target_id)`** helper.
|
||||
- **`CombatTargetHpLabel`** — shows locked target **`currentHp/maxHp`** (+ **`defeated`** suffix); **`—`** when no lock.
|
||||
- **`main.gd` wiring:** event-driven combat-target refresh on successful cast + **`target_state_changed`**; boot hydrate when first lock arrives.
|
||||
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
|
||||
- **`docs/manual-qa/NEO-85.md`** — Godot steps (server + client running).
|
||||
- **`client/README.md`** combat HUD section.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Final combat VFX, floating damage numbers art pass, player HP bar.
|
||||
- Gig XP HUD row — optional in [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone.
|
||||
- Periodic HP polling, local damage prediction, server changes.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Player sees damage/defeat feedback in HUD after digit-key cast (not Output-only).
|
||||
- [x] Target HP label matches server snapshot after cast sequence.
|
||||
- [x] Manual QA exercisable without Bruno.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Clients:** `ability_cast_client.gd` parses **`combatResolution`** on accept; **`combat_targets_client.gd`** GET world snapshot + **`target_row`**.
|
||||
- **HUD:** **`CastFeedbackLabel`** resolution copy; **`CombatTargetHpLabel`** for locked target HP; event-driven refresh on cast accept + lock change.
|
||||
- **Tests:** extended `ability_cast_client_test.gd`; new `combat_targets_client_test.gd`, `combat_feedback_refresh_test.gd`.
|
||||
- **Docs:** `client/README.md` combat HUD section; [`NEO-85` manual QA](../manual-qa/NEO-85.md); E5M1 backlog + module alignment rows updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`ability_cast_client.gd` (NEO-85)**
|
||||
- Parse optional **`combatResolution`** dict when **`accepted: true`** (`abilityId`, `targetId`, `damageDealt`, `targetRemainingHp`, `targetDefeated`).
|
||||
- Extend signal: **`cast_result_received(accepted, reason_code, resolution)`** — **`resolution`** empty **`Dictionary`** on deny or missing block.
|
||||
- Add static **`parse_cast_response_json(text) -> Dictionary`** for tests (returns `{accepted, reasonCode, resolution}`).
|
||||
|
||||
2. **`combat_targets_client.gd` (new)**
|
||||
- Mirror **`interactables_catalog_client.gd`** / **`skill_progression_client.gd`**: injectable HTTP, **`_busy`** guard.
|
||||
- **`GET {base}/game/world/combat-targets`** — no player id segment (world route).
|
||||
- **`parse_targets_json(text)`** static: require **`schemaVersion` 1**, **`targets`** array.
|
||||
- Signals: **`targets_received(snapshot: Dictionary)`**, **`targets_sync_failed(reason: String)`**.
|
||||
- **`request_sync_from_server()`** public entry.
|
||||
- **`target_row(target_id: String, snapshot: Dictionary = {}) -> Dictionary`** — find row by **`targetId`**; uses cached last snapshot when second arg omitted.
|
||||
|
||||
3. **Cast feedback copy (`main.gd` → `_on_cast_result_received`)**
|
||||
- **Deny:** unchanged NEO-28 lines (`ability_cast_denied: {reasonCode}`).
|
||||
- **Accept + resolution present:**
|
||||
- If **`targetDefeated`:** **`Cast: {damageDealt} dmg → 0 HP — defeated!`**
|
||||
- Else: **`Cast: {damageDealt} dmg → {targetRemainingHp} HP`**
|
||||
- **Accept + empty resolution:** fallback **`Cast: accepted`** (defensive; should not occur post-NEO-82).
|
||||
- Keep cooldown sync on accept (existing NEO-32 path).
|
||||
|
||||
4. **Combat target HP HUD (`main.gd`)**
|
||||
- Cache **`_combat_targets_snapshot: Dictionary`** on **`targets_received`**.
|
||||
- **`_render_combat_target_hp_label()`** — read **`_last_target_state.lockedTargetId`**; lookup row; format **`Target HP: {id} {current}/{max}`** + **` (defeated)`** when **`defeated`**; **`Target HP: — (no lock)`** when unlocked.
|
||||
- **Refresh triggers (event-driven, kickoff decision):**
|
||||
- **`target_state_changed`** → **`combat_targets_client.request_sync_from_server()`** when lock id changes or validity transitions to a locked id.
|
||||
- **`cast_result_received`** when **`accepted`** → request combat-target sync (authoritative HP after cast; resolution fields give immediate cast feedback, GET reconciles label).
|
||||
- Re-render HP label after snapshot received and when target state changes (even if GET still in flight, show last cached row for current lock).
|
||||
|
||||
5. **Scene layout (`main.tscn`)**
|
||||
- Add **`CombatTargetHpLabel`** between **`CastFeedbackLabel`** and **`GatherFeedbackLabel`** (~offset_top 374, shift **`GatherFeedbackLabel`** / **`CraftFeedbackLabel`** / economy block down ~22px if needed for non-overlap).
|
||||
- Style: match existing left HUD labels (outline, font_size 15, warm tint distinct from cast line).
|
||||
|
||||
6. **Client boot wiring (`main.gd`)**
|
||||
- Instantiate **`CombatTargetsClient`** in hotbar/cast setup block; apply authority **`base_url`** (world GET — no **`dev_player_id`**).
|
||||
- Connect **`targets_received`** → cache + **`_render_combat_target_hp_label`**.
|
||||
- Update **`cast_result_received`** connection signature for third **`resolution`** arg.
|
||||
|
||||
7. **Tests (GdUnit, AAA)**
|
||||
- **`ability_cast_client_test.gd`:** extend — accept body with **`combatResolution`** → signal carries resolution dict; deny → empty resolution.
|
||||
- **`combat_targets_client_test.gd`:** mock GET 200 two-target snapshot → **`target_row("prototype_target_alpha")`** fields; schema mismatch → failure signal.
|
||||
- **`combat_feedback_refresh_test.gd`:** harness — cast accept signal triggers **`combat_targets_client.request_sync_from_server`** (spy/mock); target lock change triggers sync (mirrors **`craft_feedback_refresh_test.gd`** / **`gather_feedback_refresh_test.gd`**).
|
||||
|
||||
8. **Docs on land**
|
||||
- **`client/README.md`:** combat HUD subsection — cast resolution copy, HP label, event-driven GET refresh, dev fixture reset note (`POST /game/__dev/combat-targets-fixture` when enabled).
|
||||
- **`docs/manual-qa/NEO-85.md`:** Tab lock alpha → bind slot 0 pulse → cast until defeat; verify **`CastFeedbackLabel`** + **`CombatTargetHpLabel`**; optional fixture reset between runs.
|
||||
- Reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-11 checkboxes, [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-85-implementation-plan.md` | This plan. |
|
||||
| `client/scripts/combat_targets_client.gd` | GET world combat-targets snapshot; parse v1; **`targets_received`** / **`targets_sync_failed`**; **`target_row`**. |
|
||||
| `client/scripts/combat_targets_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/combat_targets_client_test.gd` | GdUnit: parse two-target snapshot; **`target_row`** lookup; HTTP error path. AAA layout. |
|
||||
| `client/test/combat_feedback_refresh_test.gd` | GdUnit: cast accept + target lock change invoke combat-target sync (spy harness). AAA layout. |
|
||||
| `docs/manual-qa/NEO-85.md` | Manual QA checklist — Godot combat feedback + HP HUD without Bruno. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/ability_cast_client.gd` | Parse **`combatResolution`**; extend **`cast_result_received`** with resolution dict; static parse helper for tests. |
|
||||
| `client/test/ability_cast_client_test.gd` | Cover resolution parsing + extended signal payload on accept/deny. |
|
||||
| `client/scripts/main.gd` | Wire **`CombatTargetsClient`**; combat HP label render; cast feedback copy with resolution; event-driven refresh hooks. |
|
||||
| `client/scenes/main.tscn` | Add **`CombatTargetHpLabel`** node; adjust downstream label offsets if needed. |
|
||||
| `client/README.md` | Document combat HUD (cast resolution feedback + target HP label + refresh triggers). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `client/test/ability_cast_client_test.gd` | **Add/extend:** accept JSON with **`combatResolution`** → signal third arg has **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**; deny → empty resolution. |
|
||||
| `client/test/combat_targets_client_test.gd` | **Add:** GET parse v1; alpha/beta rows; 404/invalid schema failure signal. |
|
||||
| `client/test/combat_feedback_refresh_test.gd` | **Add:** successful cast accept triggers combat-target sync; target lock change triggers sync. |
|
||||
|
||||
**Manual verification:** [`docs/manual-qa/NEO-85.md`](manual-qa/NEO-85.md) — server + Godot; Tab lock **`prototype_target_alpha`**, digit cast **`prototype_pulse`**, observe stepping HP on label and defeat copy on 4th hit; **`target_defeated`** deny on 5th preserves HUD state.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question or risk | Agent recommendation | Status |
|
||||
|------------------|----------------------|--------|
|
||||
| **Signal signature break** | Extend **`cast_result_received`** with third arg (empty dict on deny) — update **`main.gd`** + tests in same commit. | **adopted** |
|
||||
| **HP label stale during in-flight GET** | Show last cached snapshot for locked id immediately after cast feedback; refresh when GET completes. | **adopted** |
|
||||
| **Layout shift in `main.tscn`** | Insert HP label between cast and gather lines; bump gather/craft offsets minimally. | **adopted** |
|
||||
| **Dev fixture reset for QA** | Document optional **`POST /game/__dev/combat-targets-fixture`** in manual QA when Development host enabled (NEO-83 README). | **adopted** |
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
# NEO-86 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-86 |
|
||||
| **Title** | E5M1-12: Playable tab-target combat capstone (Godot) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-86/e5m1-12-playable-tab-target-combat-capstone-godot |
|
||||
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-12** |
|
||||
| **Branch** | `NEO-86-playable-combat-capstone` |
|
||||
| **Server deps** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) cast + `combatResolution` (**Done**); [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) combat-targets GET (**Done**); [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) gig XP on defeat + GET (**Done**) |
|
||||
| **Client deps** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) combat feedback + target HP HUD (**Done**); E1.M4 cast funnel (NEO-28/31/32) (**Done**) |
|
||||
| **Pattern** | Capstone integration — [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) docs + manual QA primary; minimal client gap-fill for gig visibility |
|
||||
| **Server counterpart** | NEO-44 gig progression GET; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Gig XP visibility** | Include gig XP in the Godot capstone? | **`GigProgressionClient` + `GigXpLabel`** in economy HUD; refresh after defeat cast — NEO-44 landed; NEO-75 capstone is Godot-only (no Bruno/curl). | **User:** Godot gig label + client. |
|
||||
| **Implementation scope** | Docs-only vs client code? | **Docs + manual QA primary**; add gig client wiring; fix integration gaps only if capstone QA fails on `main`. | **User:** docs-first; code for gig row or QA failures. |
|
||||
| **Session baseline** | Fresh server vs fixture POST only? | **Fresh server restart** before Godot **F5** — resets in-memory dummy HP + gig XP baseline (NEO-75 / NEO-44 manual QA precedent). | **Adopted** (kickoff default). |
|
||||
|
||||
No other blocking decisions — combat loop HUD, hotbar pulse bootstrap, and event-driven combat-target refresh are landed in NEO-85; field names follow NEO-82 / NEO-44 server vocabulary.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Prove Epic 5 Slice 1 acceptance **in Godot**: tab-target lock, cast abilities, readable deny reasons, defeat **`prototype_target_alpha`**, and visible gig XP after defeat — without Bruno.
|
||||
|
||||
**In scope (from Linear + [E5M1-12](E5M1-prototype-backlog.md#e5m1-12--playable-tab-target-combat-capstone-godot)):**
|
||||
|
||||
- **`docs/manual-qa/NEO-86.md`**: numbered **single-session** capstone script — fresh dev player, Tab lock alpha, digit cast **`prototype_pulse`** until defeat, verify combat HUD + **`breach`** gig XP — **no Bruno/curl** steps.
|
||||
- **`client/README.md`**: **End-to-end combat loop** section — integration checklist (boot → Tab lock → cast → defeat → gig XP refresh chain); cross-links NEO-23/28/31/85.
|
||||
- **`GigProgressionClient`** + **`GigXpLabel`** — mirror **`SkillProgressionClient`** (NEO-37); boot hydrate + refresh when cast accept carries **`targetDefeated: true`**.
|
||||
- **Module alignment** (on story completion): update [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M1 row + E5M1-12 backlog checkboxes; note Epic 5 Slice 1 client capstone complete.
|
||||
- **Integration fixes only if capstone QA fails** on current `main` wiring (unexpected deny, refresh gap, etc.).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- NPC AI (E5.M2), encounters/loot (E5.M3), group scaling.
|
||||
- Final combat VFX, floating damage numbers, player HP bar.
|
||||
- Full nine-gig catalog, hub swap, gig level curves.
|
||||
- Bruno-only verification as prototype-complete proof.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Human completes **`docs/manual-qa/NEO-86.md`** with server + client; **`prototype_target_alpha`** shows defeated state in HUD.
|
||||
- [x] Epic 5 Slice 1 AC: combat readable in fixed isometric camera; no silent cast failures on deny.
|
||||
- [x] **`breach`** gig XP visible in Godot after defeat (≥ **25** from fresh-server baseline).
|
||||
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Client:** `gig_progression_client.gd` GET snapshot + **`gig_row`**; **`GigXpLabel`** under economy HUD; defeat-triggered refresh in **`_on_cast_result_received`**.
|
||||
- **Tests:** `gig_progression_client_test.gd`, `gig_feedback_refresh_test.gd` (5 cases pass).
|
||||
- **Docs:** [`NEO-86` manual QA](../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section; E5M1 backlog + module alignment rows updated.
|
||||
- **Integration:** No capstone QA gaps on `main` beyond gig client wiring.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Capstone manual QA script (`docs/manual-qa/NEO-86.md`)
|
||||
|
||||
**Preconditions:** Stop any running server; start fresh (`dotnet run`) so in-memory **`dev-local-1`** dummy HP and gig XP reset. Godot **F5** with **no** prior curl/Bruno seeding.
|
||||
|
||||
**Combat math** (from NEO-85 / NEO-82):
|
||||
|
||||
| Cast # | Expected cast feedback | Target HP label |
|
||||
|--------|------------------------|-----------------|
|
||||
| 1 | `Cast: 25 dmg → 75 HP` | `100/100` → `75/100` |
|
||||
| 2 | `→ 50 HP` | `50/100` |
|
||||
| 3 | `→ 25 HP` | `25/100` |
|
||||
| 4 | `→ 0 HP — defeated!` | `0/100 (defeated)` |
|
||||
| 5 | `ability_cast_denied: target_defeated` | unchanged defeated |
|
||||
|
||||
**Checklist outline:**
|
||||
|
||||
1. Start server + client; confirm **`Gig XP:`** row shows **`breach`** at **0** xp (boot hydrate).
|
||||
2. Walk to orange **Dummy α** near spawn; **Tab** lock **`prototype_target_alpha`** — dummy brightens; **`CombatTargetHpLabel`** shows **`100/100`**.
|
||||
3. Press **1** four times (respect ~3s pulse cooldown); verify **`CastFeedbackLabel`** + HP label step as above.
|
||||
4. Verify **`GigXpLabel`** shows **`breach`** **`xp` 25** after lethal hit (event-driven GET refresh).
|
||||
5. Press **1** again — deny feedback; gig XP unchanged.
|
||||
6. **Esc** clear lock — HP label **`— (no lock)`**; combat feedback labels remain readable.
|
||||
7. Regression spot-check: cast without lock → **`invalid_target`** deny on **`CastFeedbackLabel`**.
|
||||
|
||||
Document optional **`POST /game/__dev/combat-targets-fixture`** for mid-session dummy-only reset (NEO-85) — capstone baseline still prefers **server restart**.
|
||||
|
||||
### 2. Gig progression client (`gig_progression_client.gd`)
|
||||
|
||||
Mirror **`skill_progression_client.gd`**:
|
||||
|
||||
- **`GET {base}/game/players/{id}/gig-progression`** — injectable HTTP, **`_busy`** guard.
|
||||
- **`parse_progression_json(text)`** static — require **`schemaVersion` 1**, **`mainGigId`**, **`gigs`** array.
|
||||
- Signals: **`progression_received(snapshot: Dictionary)`**, **`progression_sync_failed(reason: String)`**.
|
||||
- **`gig_row(gig_id: String, snapshot: Dictionary = {}) -> Dictionary`** — lookup by **`id`**; uses cached snapshot when second arg omitted.
|
||||
|
||||
### 3. Gig XP HUD (`main.gd` + `main.tscn`)
|
||||
|
||||
- Add **`GigProgressionClient`** node (scene or runtime like **`CombatTargetsClient`**).
|
||||
- Add **`GigXpLabel`** under **`UICanvas/EconomyHudSection/Body/`** below **`SkillProgressionLabel`**, above **`CraftRecipePanel`** — respects NEO-75 collapse toggle.
|
||||
- **`_render_gig_xp_label()`** — format **`Gig XP:\nbreach: L1 · 25 xp`** (prototype fixed level **1** from server); **`Loading…`** / error lines match skill label pattern.
|
||||
- **Refresh triggers (event-driven):**
|
||||
- Boot: **`request_sync_from_server()`** in setup (parallel to skill progression).
|
||||
- **`cast_result_received`** when **`accepted`** and **`resolution.targetDefeated === true`** → gig progression sync (NEO-44 grant is server-side on that accept; GET reconciles HUD).
|
||||
- Do **not** refresh gig XP on every cast accept — only boot + defeat transition (minimize HTTP chatter).
|
||||
|
||||
### 4. README integration checklist
|
||||
|
||||
New **`## End-to-end combat loop (NEO-86)`** section after NEO-85 combat HUD subsection:
|
||||
|
||||
- Flow in prose: boot hotbar sync (auto pulse bind) → **Tab** lock dummy → digit cast → cast feedback + HP label refresh → defeat → gig XP refresh.
|
||||
- Cross-links: NEO-23 (targeting), NEO-28/31 (cast), NEO-85 (combat HUD), NEO-44 (server gig grant).
|
||||
- Pointer to **`docs/manual-qa/NEO-86.md`** as authoritative capstone script.
|
||||
- Note **`Economy HUD`** toggle hides gig row with other economy labels.
|
||||
|
||||
### 5. Module alignment (implementation batch or story end)
|
||||
|
||||
When acceptance criteria pass:
|
||||
|
||||
- **`documentation_and_implementation_alignment.md`**: E5.M1 — note **NEO-86 landed**, Epic 5 Slice 1 client capstone complete.
|
||||
- **`E5M1-prototype-backlog.md`**: E5M1-12 checkboxes + landed note.
|
||||
- **`E5_M1_CombatRulesEngine.md`**: E5M1-12 row if not already marked.
|
||||
|
||||
### 6. Integration fixes (conditional)
|
||||
|
||||
Run capstone QA on **`main`** wiring before gig client work; if any step fails, fix minimal **`main.gd`** / client bug in this branch and note in plan **Decisions**. Expected path: gig client + docs only.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-86-implementation-plan.md` | This plan. |
|
||||
| `docs/manual-qa/NEO-86.md` | Capstone single-session manual QA (zero Bruno/curl). |
|
||||
| `client/scripts/gig_progression_client.gd` | GET gig-progression snapshot; parse v1; **`gig_row`** helper. |
|
||||
| `client/scripts/gig_progression_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/gig_progression_client_test.gd` | GdUnit: parse snapshot; **`gig_row("breach")`**; HTTP error path. AAA layout. |
|
||||
| `client/test/gig_feedback_refresh_test.gd` | GdUnit: cast accept with **`targetDefeated`** triggers gig sync (spy harness). AAA layout. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scripts/main.gd` | Wire **`GigProgressionClient`**; **`_render_gig_xp_label`**; defeat-triggered gig refresh in **`_on_cast_result_received`**. |
|
||||
| `client/scenes/main.tscn` | Add **`GigProgressionClient`** node + **`GigXpLabel`** under economy HUD body. |
|
||||
| `client/README.md` | End-to-end combat loop section; gig XP row docs; capstone manual QA link. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row — NEO-86 landed + Slice 1 client capstone complete (on story completion). |
|
||||
| `docs/plans/E5M1-prototype-backlog.md` | E5M1-12 acceptance checkboxes + landed note (on story completion). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Path | Change |
|
||||
|------|--------|
|
||||
| `client/test/gig_progression_client_test.gd` | **Add:** GET parse v1 with **`breach`** row; invalid schema → failure signal; 404 path. |
|
||||
| `client/test/gig_feedback_refresh_test.gd` | **Add:** cast accept with **`targetDefeated: true`** triggers sync; non-defeat accept and deny do not. |
|
||||
|
||||
**Manual verification:** [`docs/manual-qa/NEO-86.md`](manual-qa/NEO-86.md) — server + Godot; full defeat spine + gig XP visibility without curl.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question or risk | Agent recommendation | Status |
|
||||
|------------------|----------------------|--------|
|
||||
| **Gig refresh on every accept** | Refresh only on **`targetDefeated`** + boot — avoids redundant GETs on non-lethal hits. | **adopted** |
|
||||
| **Gig label inside economy collapse** | Place under **`EconomyHudSection/Body`** — hidden when economy HUD collapsed (NEO-75); combat labels stay visible. | **adopted** |
|
||||
| **Overlap with NEO-85 manual QA** | NEO-86 supersedes as capstone script; keep NEO-85 as component-level regression reference. | **adopted** |
|
||||
| **Postgres dev DB retains gig XP** | Capstone preconditions require **server restart**; document that shared Postgres may show prior totals. | **adopted** |
|
||||
| **Integration gap on `main`** | Run capstone dry-run first; fix only if QA fails. | **adopted** — no logic gaps beyond gig client |
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# NEO-87 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-87 |
|
||||
| **Title** | E5M2-01: Prototype NpcBehaviorDef catalog + schemas + CI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-87/e5m2-01-prototype-npcbehaviordef-catalog-schemas-ci |
|
||||
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-01** |
|
||||
| **Branch** | `NEO-87-e5m2-npc-behavior-catalog-schemas-ci` |
|
||||
| **Precursor** | [E5.M1](../decomposition/modules/E5_M1_CombatRulesEngine.md) **Ready** — passive dummies remain until E5M2-05 |
|
||||
| **Pattern** | [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) — row JSON Schema + catalog envelope + `scripts/validate_content.py` gate |
|
||||
| **Blocks** | [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) — server NPC behavior catalog load |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Radius / cooldown stat freeze** | `aggroRadius`, `leashRadius`, `attackCooldownSeconds` per archetype? | **Archetype-specific table** — melee 8/16/3.0 · ranged 10/20/4.0 · elite 8/18/5.0; radii sit above 6 m tab-lock; leash > aggro for anti-kite. | **User:** archetype-specific table (adopted below). |
|
||||
| **Catalog envelope** | File shape? | `{ "schemaVersion": 1, "npcBehaviors": [ … ] }` in `content/npc-behaviors/prototype_npc_behaviors.json` (mirror abilities / items). | **Adopted** (NEO-76 precedent). |
|
||||
| **CI constant name** | Slice gate identifier? | **`PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS`** — Epic 5 Slice 2; avoids collision with E5M1 ability gate. | **Adopted**. |
|
||||
| **Client counterpart** | Godot issue for this story? | **None** — server-only content + CI; player-visible work starts at E5M2-04+ / [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). | **Adopted** (E5M2-01 out of scope). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Lock content shape and CI validation for **three** frozen `NpcBehaviorDef` rows before server load (NEO-88+).
|
||||
|
||||
**In scope (from Linear + [E5M2-01](E5M2-prototype-backlog.md#e5m2-01--prototype-npcbehaviordef-catalog--schemas--ci)):**
|
||||
|
||||
- `content/schemas/npc-behavior-def.schema.json`.
|
||||
- `content/npc-behaviors/prototype_npc_behaviors.json` — three frozen ids (full freeze table below).
|
||||
- `scripts/validate_content.py` — schema validation, duplicate `id`, exact three-id allowlist, positive numeric guards (`leashRadius` > `aggroRadius`).
|
||||
- Expand **Prototype Slice 2 freeze** table in [E5_M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) + designer note in `content/README.md`.
|
||||
- **CT.M1** PR gate paragraph for NPC behavior catalogs.
|
||||
- `documentation_and_implementation_alignment.md` E5.M2 row → note NEO-87 catalog in progress / landed.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server loader, runtime engine, HTTP, Godot (NEO-88+).
|
||||
- NPC instance registry / dummy migration (E5M2-05).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR gate validates NPC behavior JSON against schema.
|
||||
- [x] Exactly three prototype behavior ids; duplicate `id` fails CI.
|
||||
- [x] Stable id list documented in module doc freeze box.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Schema:** [`npc-behavior-def.schema.json`](../../content/schemas/npc-behavior-def.schema.json) — required `id`, `displayName`, `archetypeKind`, `maxHp`, `aggroRadius`, `leashRadius`, `telegraphWindupSeconds`, `attackDamage`, `attackCooldownSeconds`.
|
||||
- **Catalog:** [`prototype_npc_behaviors.json`](../../content/npc-behaviors/prototype_npc_behaviors.json) — three frozen rows per kickoff stat table.
|
||||
- **CI:** `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS` gate + `leashRadius > aggroRadius` guard in [`validate_content.py`](../../scripts/validate_content.py).
|
||||
- **Docs:** E5.M2 full freeze table, `content/README.md`, CT.M1, alignment register, E5M2-01 backlog checkboxes.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Row schema (`npc-behavior-def.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `id`, `displayName`, `archetypeKind`, `maxHp`, `aggroRadius`, `leashRadius`, `telegraphWindupSeconds`, `attackDamage`, `attackCooldownSeconds`. `id` pattern `^[a-z][a-z0-9_]*$`. `displayName` `minLength: 1`. `archetypeKind` enum `melee_pressure` \| `ranged_control` \| `elite_mini_boss`. `maxHp` integer ≥ 1. `attackDamage` integer ≥ 1. `aggroRadius`, `leashRadius`, `telegraphWindupSeconds`, `attackCooldownSeconds` number with `exclusiveMinimum: 0` (strictly positive).
|
||||
|
||||
2. **Catalog file:** `content/npc-behaviors/prototype_npc_behaviors.json` with `schemaVersion: 1` and three rows from the kickoff-adopted freeze table.
|
||||
|
||||
3. **Frozen content values (E5M2-01 — kickoff adopted):**
|
||||
|
||||
| `NpcBehaviorDef.id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` |
|
||||
|---------------------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------|
|
||||
| `prototype_melee_pressure` | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 |
|
||||
| `prototype_ranged_control` | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 |
|
||||
| `prototype_elite_mini_boss` | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 |
|
||||
|
||||
**Rules:** Do **not** rename frozen behavior **`id`** values after ship — change **`displayName`** only, or add a new `id` in a follow-up issue. CI enforces **exactly** these three ids. Tune combat feel via numeric fields or new ids in a follow-up issue.
|
||||
|
||||
4. **`validate_content.py`:**
|
||||
- Add `NPC_BEHAVIOR_SCHEMA`, `NPC_BEHAVIORS_DIR`; discover `content/npc-behaviors/*_npc_behaviors.json`.
|
||||
- `_validate_npc_behavior_catalogs`: envelope `schemaVersion: 1`, top-level `npcBehaviors` array; validate each row against `npc-behavior-def.schema.json`; track `seen_ids`; reject duplicates across files.
|
||||
- **`_prototype_e5m2_npc_behavior_gate`:** ids must equal `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS` (frozenset of three ids above).
|
||||
- **`_prototype_e5m2_npc_behavior_numeric_gate`:** after schema pass, require `leashRadius > aggroRadius` per row (backlog anti-kite invariant).
|
||||
- Run NPC behavior validation after ability catalogs succeed (no cross-catalog refs in E5M2-01).
|
||||
- Require NPC behavior schema file in `main()` startup checks; extend module docstring + success summary with npc-behavior file + id counts.
|
||||
- Keep `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS` in sync with [E5.M2 freeze table](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01) and future NEO-88 server loader.
|
||||
|
||||
5. **Docs:**
|
||||
- `content/README.md` — add `npc-behaviors/` table row + E5 Slice 2 NPC behavior freeze paragraph.
|
||||
- `E5_M2_NpcAiAndBehaviorProfiles.md` — expand **Prototype Slice 2 freeze (E5M2-01)** table with full column set + CI rules line.
|
||||
- `CT_M1_ContentValidationPipeline.md` — NPC behavior catalog PR gate paragraph.
|
||||
- `documentation_and_implementation_alignment.md` — E5.M2 row notes NEO-87 catalog when complete.
|
||||
- `E5M2-prototype-backlog.md` — E5M2-01 checkboxes when implementation completes.
|
||||
|
||||
6. **No server/C#/client changes** in this story.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/npc-behavior-def.schema.json` | JSON Schema for a single `NpcBehaviorDef` row. |
|
||||
| `content/npc-behaviors/prototype_npc_behaviors.json` | Prototype three-behavior catalog (`schemaVersion` + `npcBehaviors` array). |
|
||||
| `docs/plans/NEO-87-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `scripts/validate_content.py` | Load/validate NPC behavior catalogs; duplicate `id`; E5M2 three-id gate; leash > aggro guard; success summary. |
|
||||
| `content/README.md` | Document `content/npc-behaviors/`, schema path, E5 Slice 2 freeze (active catalog). |
|
||||
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | Full freeze table + CI enforcement note; link to plan when complete. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for NPC behavior catalog validation. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 / NEO-87 status after catalog lands. |
|
||||
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-01 checkboxes + landed note when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| **CI / PR gate** | `.github/workflows/pr-gate.yml` already runs `python scripts/validate_content.py` — extended script is the regression signal. |
|
||||
| **Manual negative cases** | From repo root after implementation: (1) duplicate behavior `id` → non-zero exit; (2) remove one frozen id → gate error; (3) break schema (zero `aggroRadius` or `leashRadius <= aggroRadius`) → schema or numeric gate error; (4) empty `displayName` → schema error. |
|
||||
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports npc-behavior catalog + existing catalogs OK. |
|
||||
|
||||
No dedicated pytest module in-repo today (same as NEO-76 / NEO-50).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Catalog growth** | E5M2 gate requires **exactly** the three frozen ids; expanding the roster needs a follow-up issue to change `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS`. | **adopted** |
|
||||
| **C# vs Python drift** | None until NEO-88; document “keep in sync” constant names in both places when server load lands (mirror NEO-77 / `PROTOTYPE_E5M1_ABILITY_IDS`). | **adopted** |
|
||||
| **Radii vs tab-lock 6 m** | `aggroRadius` 8–10 m keeps proximity re-aggro usable in the combat pocket without matching tab-lock exactly; E5M2-06 distance checks use behavior-def radii, not `PrototypeTargetRegistry.SharedLockRadius`. | **adopted** |
|
||||
| **Runtime still uses alpha/beta dummies** | Expected — content lands before E5M2-05 migrates combat targets to NPC instance ids. | **adopted** |
|
||||
|
||||
None blocking.
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
# NEO-88 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-88 |
|
||||
| **Title** | E5M2-02: Server NPC behavior catalog load (fail-fast) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-88/e5m2-02-server-npc-behavior-catalog-load-fail-fast |
|
||||
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-02** |
|
||||
| **Branch** | `NEO-88-e5m2-server-npc-behavior-catalog-load` |
|
||||
| **Precursor** | [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) — frozen three-behavior catalog + CI gates (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket |
|
||||
| **Blocks** | [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) — `INpcBehaviorDefinitionRegistry` + DI (E5M2-03) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **NEO-88 vs NEO-89 (DI scope)** | Ship `INpcBehaviorDefinitionRegistry` on this story, or only load + `NpcBehaviorDefinitionCatalog`? | **Strict split** — NEO-88: loader + `NpcBehaviorDefinitionCatalog` + eager fail-fast boot; [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) adds injectable `INpcBehaviorDefinitionRegistry` (E5M2-03). Rationale: E5M2 backlog separates E5M2-02/03; mirrors NEO-77/NEO-79. | **User:** `NpcBehaviorDefinitionCatalog` singleton only (kickoff). |
|
||||
| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror `AbilityDefinitionCatalogLoaderTests` (NEO-77). | **User:** loader AAA + host boot tests (kickoff). |
|
||||
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + E5M2 three-id gate + leash > aggro numeric gate). Rationale: NEO-77 precedent; no cross-catalog refs in Slice 2. | **Adopted** — NEO-77 precedent; no separate question. |
|
||||
| **Client counterpart** | Godot issue for this story? | **None** — server-only load; player-visible work starts at E5M2-04+ / [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). | **Adopted** (E5M2-02 out of scope). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** On host startup, load all `content/npc-behaviors/*_npc_behaviors.json` catalogs (validated in CI per [NEO-87](NEO-87-implementation-plan.md)), build an in-memory **`NpcBehaviorDefinitionCatalog`**, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, fails the prototype E5M2 three-id gate, or violates the leash > aggro numeric invariant.
|
||||
|
||||
**In scope (from Linear + [E5M2-02](E5M2-prototype-backlog.md#e5m2-02--server-npc-behavior-catalog-load-fail-fast)):**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Npc/`.
|
||||
- Configurable npc-behaviors directory and schema path (`ContentPathsOptions` extension).
|
||||
- CI-parity validation: `schemaVersion`, row schema (`npc-behavior-def`), duplicate `id`, E5M2 three-id allowlist gate, **`leashRadius > aggroRadius`** per row.
|
||||
- DI registration of **`NpcBehaviorDefinitionCatalog`** singleton; eager resolve in `Program.cs`.
|
||||
- Unit + host startup tests (AAA).
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- **`INpcBehaviorDefinitionRegistry`** / DI registry (**NEO-89**, E5M2-03).
|
||||
- HTTP projection (**NEO-90**, E5M2-04), NPC runtime tick, dummy migration (E5M2-05+).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Server refuses boot when NPC behavior catalog invalid (missing dir, no `*_npc_behaviors.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, E5M2 gate failure, leash ≤ aggro).
|
||||
- [x] `NpcBehaviorDefinitionCatalog` resolves prototype rows by behavior `id` (e.g. `prototype_melee_pressure`) with all schema fields on the row DTO.
|
||||
- [x] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory.
|
||||
- [x] Success log includes **behavior count**, **catalog directory**, and **file count** (Information).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Loader:** [`NpcBehaviorDefinitionCatalogLoader`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs) — CI-parity validation via JsonSchema.Net + E5M2 id gate + leash > aggro numeric gate.
|
||||
- **Catalog:** [`NpcBehaviorDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs) + [`NpcBehaviorDefRow`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs) — `TryGetBehavior` lookup by id.
|
||||
- **Gate rules:** [`PrototypeE5M2NpcBehaviorCatalogRules`](../../server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs) — sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS`.
|
||||
- **DI / startup:** [`NpcBehaviorCatalogServiceCollectionExtensions`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs) + eager resolve in [`Program.cs`](../../server/NeonSprawl.Server/Program.cs).
|
||||
- **Tests:** [`NpcBehaviorDefinitionCatalogLoaderTests`](../../server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs) — 16 AAA cases (loader + host).
|
||||
- **Docs:** `server/README.md` NPC behavior catalog section; E5.M2 module doc + alignment register + E5M2 backlog E5M2-02 checkboxes.
|
||||
- **Unchanged:** No `INpcBehaviorDefinitionRegistry` — migration deferred to NEO-89.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`Game/Npc/` row DTO** — `NpcBehaviorDefRow` (`Id`, `DisplayName`, `ArchetypeKind`, `MaxHp`, `AggroRadius`, `LeashRadius`, `TelegraphWindupSeconds`, `AttackDamage`, `AttackCooldownSeconds`). `ArchetypeKind` stored as `string` matching schema enum values (mirror `AbilityDefRow.AbilityKind` pattern).
|
||||
|
||||
2. **`NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDirectory, schemaPath, logger)`** — single-pass load mirroring `validate_content.py` `_validate_npc_behavior_catalogs` + `_prototype_e5m2_npc_behavior_gate` + `_prototype_e5m2_npc_behavior_numeric_gate`:
|
||||
- Enumerate `*_npc_behaviors.json` (top-level only), ordered by path.
|
||||
- Build **JsonSchema.Net** evaluator from `npc-behavior-def.schema.json`.
|
||||
- Per file: parse JSON; require `schemaVersion == 1` and top-level `npcBehaviors` array.
|
||||
- Per row: validate against schema; on clean rows track `id`; build `NpcBehaviorDefRow`; reject duplicate `id` across files.
|
||||
- Run **`PrototypeE5M2NpcBehaviorCatalogRules.TryGetE5M2GateError`** (mirror `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS` / `_prototype_e5m2_npc_behavior_gate`).
|
||||
- Run **`PrototypeE5M2NpcBehaviorCatalogRules.TryGetNumericGateError`** (mirror `_prototype_e5m2_npc_behavior_numeric_gate`: `leashRadius > aggroRadius`).
|
||||
- Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `NPC behavior catalog validation failed:`.
|
||||
|
||||
3. **`NpcBehaviorDefinitionCatalog`** — immutable snapshot: `NpcBehaviorsDirectory`, `ById`, `DistinctBehaviorCount`, `CatalogJsonFileCount`; `TryGetBehavior(id, out NpcBehaviorDefRow?)` for lookups.
|
||||
|
||||
4. **`NpcBehaviorCatalogPathResolution`** — `TryDiscoverNpcBehaviorsDirectory`, `ResolveNpcBehaviorsDirectory`, `ResolveNpcBehaviorDefSchemaPath` (parent `content/schemas/` layout when unset).
|
||||
|
||||
5. **`PrototypeE5M2NpcBehaviorCatalogRules`** — frozen three behavior ids; sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS`. Ids: `prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss` (same set as [E5.M2 freeze table](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01)).
|
||||
|
||||
6. **`NpcBehaviorCatalogServiceCollectionExtensions.AddNpcBehaviorDefinitionCatalog`** — bind `ContentPathsOptions`; register **`NpcBehaviorDefinitionCatalog`** singleton factory that resolves paths and calls loader. No dependency on ability/item catalogs (Slice 2 behavior rows have no cross-ref fields).
|
||||
|
||||
7. **`Program.cs`** — `AddNpcBehaviorDefinitionCatalog(configuration)` alongside existing catalog registrations; after `Build()`, `GetRequiredService<NpcBehaviorDefinitionCatalog>()` with other eager catalog resolves.
|
||||
|
||||
8. **Tests** — temp-dir fixtures copying repo `npc-behavior-def.schema.json`; valid three-behavior JSON matching [prototype_npc_behaviors.json](../../content/npc-behaviors/prototype_npc_behaviors.json); negative cases (duplicate id, wrong/missing E5M2 roster, bad numeric fields, empty `displayName`, `schemaVersion` ≠ 1, missing dir/schema, no `*_npc_behaviors.json`, invalid JSON, `leashRadius <= aggroRadius`); `InMemoryWebApplicationFactory` host test + `WebApplicationFactory` with empty npc-behaviors dir fails startup.
|
||||
|
||||
9. **Test factories** — pin `Content:NpcBehaviorsDirectory` in `InMemoryWebApplicationFactory` and other `WebApplicationFactory` subclasses that already pin recipes/items/abilities (same pattern as NEO-77).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs` | Load-time `NpcBehaviorDef` row DTO. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs` | In-memory catalog + `TryGetBehavior`. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs` | Disk I/O, schema validation, E5M2 gates. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs` | Frozen three ids + numeric gate (sync comment to Python). |
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs` | `AddNpcBehaviorDefinitionCatalog` DI registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs` | Repo `content/npc-behaviors` + schema discovery for tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests. |
|
||||
| `docs/plans/NEO-88-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `NpcBehaviorsDirectory`, `NpcBehaviorDefSchemaPath` under `Content` section. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `NpcBehaviorDefinitionCatalog` at startup. |
|
||||
| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:NpcBehaviorsDirectory` / schema path keys for override documentation (mirror other catalogs). |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:NpcBehaviorsDirectory` to discovered repo path so existing tests keep booting. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Pin npc-behaviors directory (same as recipes/items/abilities). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Pin npc-behaviors directory. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Pin npc-behaviors directory. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs` | Pin npc-behaviors directory. |
|
||||
| `server/README.md` | New **NPC behavior catalog (`content/npc-behaviors`, NEO-88)** section (config keys, discovery, fail-fast, E5M2 parity with CI). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — note NEO-88 server load when implementation completes. |
|
||||
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | Status line — E5M2-02 server load landed when complete. |
|
||||
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-02 checkboxes when implementation completes. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs` | **Unit:** valid three-behavior prototype catalog loads; `npcBehaviors` not array; schema violation (zero `aggroRadius`, empty `displayName`, invalid `maxHp`); duplicate `id` across files; E5M2 gate failure (missing id + extra/wrong id); numeric gate failure (`leashRadius <= aggroRadius`); `schemaVersion` ≠ 1; missing dir/schema; no `*_npc_behaviors.json` files; invalid JSON. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `NpcBehaviorDefinitionCatalog` count 3; `WebApplicationFactory` with empty npc-behaviors dir fails startup with actionable message. |
|
||||
|
||||
**Manual QA:** Skipped — no player-visible HTTP; acceptance is fully covered by AAA loader/host tests above.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Constants drift vs Python** | Comment on `PrototypeE5M2NpcBehaviorCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS` / gate helpers. | **adopted** |
|
||||
| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:NpcBehaviorsDirectory` explicitly (do not rely on discovery alone in CI). | **adopted** |
|
||||
| **Registry consumers** | Runtime NPC logic uses `INpcBehaviorDefinitionRegistry` in NEO-89+; this story only exposes `NpcBehaviorDefinitionCatalog` for boot + direct test lookup. | **adopted** |
|
||||
| **Alpha/beta dummies still active** | Expected — catalog lands before E5M2-05 migrates combat targets to NPC instance ids. | **adopted** |
|
||||
| **Numeric gate not in JSON Schema** | Enforce in C# gate class (same as Python `_prototype_e5m2_npc_behavior_numeric_gate`); schema only requires strictly positive radii. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **`NpcBehaviorDefinitionCatalog` singleton only** — defer `INpcBehaviorDefinitionRegistry` to NEO-89 (user confirmed kickoff).
|
||||
- **In-process validation** + **loader and host boot tests** per NEO-77 precedent.
|
||||
- **No cross-catalog checks** — behavior rows have no ability/item refs in Slice 2; loader has no catalog dependencies.
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
# NEO-89 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-89 |
|
||||
| **Title** | E5M2-03: Injectable `INpcBehaviorDefinitionRegistry` + DI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-89/e5m2-03-injectable-inpcbehaviordefinitionregistry-di |
|
||||
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-03** |
|
||||
| **Branch** | `NEO-89-inpcbehaviordefinitionregistry-di` |
|
||||
| **Precursor** | [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) — fail-fast `NpcBehaviorDefinitionCatalog` load (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) / [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) — thin registry adapter over startup catalog + DI; strict split after NEO-88 loader |
|
||||
| **Blocks** | [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) — GET world npc-behavior-definitions; [NEO-91+](E5M2-prototype-backlog.md) — NPC instance registry, runtime, aggro |
|
||||
| **Client counterpart** | None (server-only); player-visible definitions HTTP is [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90); Godot capstone [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **`GetDefinitionsInIdOrder()`** | Include enumeration API now? | **Yes** — E5M2 backlog + NEO-79; [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) needs ordered defs without `NpcBehaviorDefinitionCatalog`. | **User:** include enumeration. |
|
||||
| **`PrototypeNpcBehaviorRegistry`** | Add three `const` behavior id strings for tests/fixtures? | **Yes** — mirror `PrototypeAbilityRegistry`; keep `PrototypeE5M2NpcBehaviorCatalogRules` for gate validation only. | **User:** add constants class. |
|
||||
| **Lookup naming** | `TryGetDefinition` vs `TryGetBehavior`? | **`TryGetDefinition(string? behaviorId, …)`** — same as ability/item/skill registries; catalog keeps `TryGetBehavior`. | **Adopted** — NEO-79 precedent; no separate question. |
|
||||
| **`Program.cs` eager resolve** | Eager-resolve `INpcBehaviorDefinitionRegistry` at boot? | **Omit** — `NpcBehaviorDefinitionCatalog` already eager in `Program.cs` (NEO-88); registry is thin adapter (NEO-79). | **Adopted** — NEO-79 precedent. |
|
||||
| **Call-site migration** | Migrate any handlers off catalog this story? | **None** — no game handlers inject catalog yet; HTTP/runtime consumers land in NEO-90+. | **Adopted** — repo has no catalog consumers beyond boot/tests. |
|
||||
| **Client counterpart** | Godot issue for this story? | **None** — server-only DI; cross-link [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) for player-visible verification. | **Adopted** (E5M2-03 out of scope). |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Provide **`INpcBehaviorDefinitionRegistry`** backed by the startup-loaded **`NpcBehaviorDefinitionCatalog`**: resolve by stable behavior **`id`**, enumerate definitions in id order, and expose **`TryNormalizeKnown`** for future instance-binding / HTTP validation. Register in DI so game code depends on the interface, not the catalog singleton.
|
||||
|
||||
**In scope (from Linear + [E5M2-03](E5M2-prototype-backlog.md#e5m2-03--injectable-inpcbehaviordefinitionregistry--di)):**
|
||||
|
||||
- `INpcBehaviorDefinitionRegistry` + `NpcBehaviorDefinitionRegistry` thin adapter over `NpcBehaviorDefinitionCatalog`.
|
||||
- `PrototypeNpcBehaviorRegistry` with three frozen id constants (aligned with [E5.M2 freeze box](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md)).
|
||||
- DI registration in `AddNpcBehaviorDefinitionCatalog`.
|
||||
- Unit tests (AAA): known-id lookup with archetype metadata; unknown-id miss; `TryNormalizeKnown` trim/case behavior; enumeration order; host resolves registry from DI.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- HTTP GET route ([NEO-90](https://linear.app/neon-sprawl/issue/NEO-90)).
|
||||
- NPC instance registry, aggro, runtime tick, combat-target migration ([NEO-91+](E5M2-prototype-backlog.md)).
|
||||
- Changing loader, E5M2 gate, or catalog load semantics ([NEO-88](https://linear.app/neon-sprawl/issue/NEO-88)).
|
||||
- Godot / client changes.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Registry resolves all three frozen behavior ids (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`) with expected row metadata from content.
|
||||
- [x] Unknown id normalization / lookup fails closed (`TryGetDefinition` and `TryNormalizeKnown` return false without throwing).
|
||||
- [x] `GetDefinitionsInIdOrder` returns three rows ordered by `NpcBehaviorDefRow.Id` (ordinal).
|
||||
- [x] DI resolves `INpcBehaviorDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
|
||||
- [x] Unit tests (AAA): lookup for known prototype ids; unknown id returns false; `TryNormalizeKnown` accepts trimmed/case-variant input for catalog ids.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **`INpcBehaviorDefinitionRegistry`** + **`NpcBehaviorDefinitionRegistry`** registered in **`AddNpcBehaviorDefinitionCatalog`**; catalog-only eager resolve unchanged in **`Program.cs`**.
|
||||
- **`PrototypeNpcBehaviorRegistry`** — three frozen behavior id constants for tests/fixtures.
|
||||
- **12** new AAA tests in **`NpcBehaviorDefinitionRegistryTests`** (includes case-variant **`TryGetDefinition`** deny); **28** total Npc-filter tests green (includes existing loader tests).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`INpcBehaviorDefinitionRegistry`** — mirror [`IAbilityDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs):
|
||||
- `TryGetDefinition(string? behaviorId, [NotNullWhen(true)] out NpcBehaviorDefRow? definition)` — null and unknown ids return false without throwing; exact ordinal match on catalog keys.
|
||||
- `TryNormalizeKnown(string? rawBehaviorId, [NotNullWhen(true)] out string normalized)` — trim, lowercase, empty/whitespace → false; success when normalized id exists in catalog.
|
||||
- `GetDefinitionsInIdOrder()` — all rows ordered by `NpcBehaviorDefRow.Id` (ordinal).
|
||||
- XML remarks: HTTP read model ([NEO-90](https://linear.app/neon-sprawl/issue/NEO-90)) and NPC runtime ([NEO-91+](E5M2-prototype-backlog.md)) should depend on this interface, not `NpcBehaviorDefinitionCatalog`.
|
||||
|
||||
2. **`NpcBehaviorDefinitionRegistry`** — primary-constructor adapter over `NpcBehaviorDefinitionCatalog` (same pattern as [`AbilityDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs)); precompute ordered list at construction; delegate lookups to `catalog.TryGetBehavior`.
|
||||
|
||||
3. **`PrototypeNpcBehaviorRegistry`** — public const strings for the three frozen ids; class summary points at `INpcBehaviorDefinitionRegistry` for validation/lookup (no static allowlist/`TryNormalizeKnown`).
|
||||
|
||||
4. **DI** — extend [`AddNpcBehaviorDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs) to register `INpcBehaviorDefinitionRegistry` → `NpcBehaviorDefinitionRegistry` after catalog singleton. No change to `Program.cs` eager-resolve (catalog only).
|
||||
|
||||
5. **Docs** — tighten [`server/README.md`](../../server/README.md) NPC behavior section if needed (registry is the preferred lookup; catalog for fail-fast boot only). Optional one-line note in [E5M2-prototype-backlog.md](E5M2-prototype-backlog.md) E5M2-03 row when implementation lands.
|
||||
|
||||
6. **Tests** — new `NpcBehaviorDefinitionRegistryTests.cs` mirroring `AbilityDefinitionRegistryTests`: in-memory catalog helper, loader-backed prototype fixture via `NpcBehaviorCatalogTestPaths` + repo `prototype_npc_behaviors.json`, host DI test asserting three freeze-box rows.
|
||||
|
||||
### Expected prototype lookup values (from content)
|
||||
|
||||
| `id` | `maxHp` | `aggroRadius` | `leashRadius` | `attackDamage` | `telegraphWindupSeconds` |
|
||||
|------|---------|---------------|---------------|----------------|--------------------------|
|
||||
| `prototype_melee_pressure` | 100 | 8.0 | 16.0 | 15 | 1.5 |
|
||||
| `prototype_ranged_control` | 80 | 10.0 | 20.0 | 12 | 2.0 |
|
||||
| `prototype_elite_mini_boss` | 200 | 8.0 | 18.0 | 25 | 2.5 |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Npc/INpcBehaviorDefinitionRegistry.cs` | Public contract: lookup, normalize, enumerate; remarks for NEO-90+ callers. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionRegistry.cs` | Singleton adapter over `NpcBehaviorDefinitionCatalog`. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/PrototypeNpcBehaviorRegistry.cs` | Stable prototype behavior id constants for tests/fixtures. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs` | AAA unit + host DI tests (mirror `AbilityDefinitionRegistryTests`). |
|
||||
| `docs/plans/NEO-89-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs` | Register `INpcBehaviorDefinitionRegistry` → `NpcBehaviorDefinitionRegistry` after catalog singleton; update method summary. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs` | Reference `INpcBehaviorDefinitionRegistry` in summary (replace NEO-89 placeholder). |
|
||||
| `server/README.md` | Confirm registry is preferred lookup path; catalog reserved for fail-fast boot. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` for melee/ranged/elite with expected `MaxHp`, `AggroRadius`, `AttackDamage`, `ArchetypeKind`; unknown id + null → false; `TryNormalizeKnown` with whitespace/mixed case; empty/whitespace → false; `GetDefinitionsInIdOrder` count 3 + ordinal id order. **Loader fixture:** repo `prototype_npc_behaviors.json` via `NpcBehaviorCatalogTestPaths`, assert all three freeze-box values. **Host:** `InMemoryWebApplicationFactory` resolves `INpcBehaviorDefinitionRegistry` from DI; lookup + enumeration smoke. |
|
||||
| Existing `NpcBehaviorDefinitionCatalogLoaderTests` | **Regression:** no behavior change expected; run Npc test filter after implementation. |
|
||||
|
||||
No manual QA for this story (server-only DI; no HTTP or Godot surface). Bruno proof is [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Case-only mismatch** — caller sends id not in catalog after normalize | **Fail closed** — catalog ids are lowercase frozen strings; `TryNormalizeKnown` lowercases input. | **adopted** |
|
||||
| **Dual lookup surfaces** — `TryGetDefinition` vs `TryNormalizeKnown` | **Keep both** — future instance binding/HTTP may need normalize-only; runtime needs row lookup without duplicating trim rules. | **adopted** |
|
||||
| **Duplicate id source** — constants vs `ExpectedBehaviorIds` | **Constants for tests**; `PrototypeE5M2NpcBehaviorCatalogRules` remains authoritative for loader/CI gate set equality. | **adopted** |
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
# NEO-90 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-90 |
|
||||
| **Title** | E5M2-04: GET `/game/world/npc-behavior-definitions` |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-90/e5m2-04-get-gameworldnpc-behavior-definitions |
|
||||
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-04** |
|
||||
| **Branch** | `NEO-90-npc-behavior-definitions-get` |
|
||||
| **Precursor** | [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) — `INpcBehaviorDefinitionRegistry` + DI (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) — `GET /game/world/ability-definitions`; [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) — recipe definitions world GET |
|
||||
| **Client counterpart** | None (server-only); player-visible labels/HUD land in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) / capstone [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Response array key** | Top-level JSON array name? | **`npcBehaviors`** — matches `content/npc-behaviors/*.json` catalog envelope. | **User:** `npcBehaviors`. |
|
||||
| **Manual QA doc** | Add `docs/manual-qa/NEO-90.md`? | **Skip** — server-only HTTP; Bruno + integration tests (NEO-78/89 pattern). | **User:** skip. |
|
||||
|
||||
No other blocking decisions — route path, `schemaVersion` **1**, registry injection, row fields (all required schema fields), and frozen three-id order are specified in [E5M2-04](E5M2-prototype-backlog.md#e5m2-04--get-gameworldnpc-behavior-definitions) and the [E5.M2 freeze box](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose a stable, read-only JSON endpoint that returns all three loaded prototype NPC behavior definitions for Bruno, tooling, and future client labels ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)) — backed by **`INpcBehaviorDefinitionRegistry`** without duplicating catalog truth.
|
||||
|
||||
**In scope (from Linear + [E5M2-04](E5M2-prototype-backlog.md#e5m2-04--get-gameworldnpc-behavior-definitions)):**
|
||||
|
||||
- **`GET /game/world/npc-behavior-definitions`** with versioned envelope (`schemaVersion` **1**, **`npcBehaviors`** array).
|
||||
- Row fields: **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`** (all required in v1; no optional omissions).
|
||||
- Behaviors ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
|
||||
- Bruno folder `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||
- `server/README.md` route section.
|
||||
- API integration tests (AAA).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Runtime state, telegraphs, aggro, NPC instance registry ([NEO-91+](E5M2-prototype-backlog.md)).
|
||||
- Godot client wiring ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)).
|
||||
- `docs/manual-qa/NEO-90.md` (kickoff decision).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] GET returns all **three** prototype behavior defs with **`schemaVersion`** **1** and stable field names.
|
||||
- [x] Rows appear in ascending **`id`** order (ordinal).
|
||||
- [x] Bruno happy GET passes in CI Bruno step.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`GET /game/world/npc-behavior-definitions`** — parameterless read; inject **`INpcBehaviorDefinitionRegistry`** only (not **`NpcBehaviorDefinitionCatalog`**).
|
||||
|
||||
2. **Response shape:** Top-level **`schemaVersion`** (`NpcBehaviorDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`npcBehaviors`** array. Each row maps from **`NpcBehaviorDefRow`**: all nine schema fields with camelCase JSON names matching existing world-definition APIs.
|
||||
|
||||
3. **Ordering:** Build list from **`registry.GetDefinitionsInIdOrder()`** (already ordinal by id). Tests assert exact id sequence (frozen three from [E5.M2 freeze box](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01)):
|
||||
|
||||
`prototype_elite_mini_boss`, `prototype_melee_pressure`, `prototype_ranged_control`
|
||||
|
||||
(ordinal sort of the three ids — same order as **`NpcBehaviorDefinitionRegistryTests`** / **`PrototypeNpcBehaviorRegistry`** constants).
|
||||
|
||||
4. **Implementation:** New **`NpcBehaviorDefinitionsWorldApi`** + **`NpcBehaviorDefinitionsListDtos.cs`** in `Game/Npc/` (mirror [`AbilityDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs)). Wire **`app.MapNpcBehaviorDefinitionsWorldApi()`** from **`Program.cs`** after other world definition APIs (near **`MapAbilityDefinitionsWorldApi()`**).
|
||||
|
||||
5. **Bruno:** `bruno/neon-sprawl-server/npc-behavior-definitions/` with **`Get npc behavior definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`npcBehaviors.length === 3`**, ascending id order, **exact frozen-three registry sequence**, spot-check **`prototype_melee_pressure`** and **`prototype_elite_mini_boss`** metadata.
|
||||
|
||||
6. **Docs:** Add **`server/README.md`** subsection under NPC behavior catalog documenting GET + curl example. Update [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E5.M2 row when landed.
|
||||
|
||||
### Expected prototype row values (from content)
|
||||
|
||||
| `id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` |
|
||||
|------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------|
|
||||
| `prototype_melee_pressure` | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 |
|
||||
| `prototype_ranged_control` | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 |
|
||||
| `prototype_elite_mini_boss` | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps registry → JSON. |
|
||||
| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs` | Versioned response + row DTOs for JSON serialization. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, three ids in order, spot-check rows. |
|
||||
| `bruno/neon-sprawl-server/npc-behavior-definitions/Get npc behavior definitions.bru` | Manual / Bruno verification against dev server. |
|
||||
| `bruno/neon-sprawl-server/npc-behavior-definitions/folder.bru` | Bruno folder metadata (match `ability-definitions`). |
|
||||
| `docs/plans/NEO-90-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapNpcBehaviorDefinitionsWorldApi()` alongside other game world definition APIs. |
|
||||
| `server/README.md` | Document `GET /game/world/npc-behavior-definitions`, curl example, links to plan/Bruno. |
|
||||
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Related implementation slices** — HTTP read model bullet (NEO-90). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — note NEO-90 HTTP projection when landed. |
|
||||
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-04 acceptance checkboxes + landed note when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionsWorldApiTests.cs` | **Integration:** `GET /game/world/npc-behavior-definitions` via **`InMemoryWebApplicationFactory`**; **`ReadFromJsonAsync`**; assert **`schemaVersion` 1**; **`npcBehaviors`** count **3**; ordered **`id`** list equals frozen three in ordinal id order; spot-check **`prototype_melee_pressure`** (maxHp 100, attackDamage 15, archetypeKind `melee_pressure`) and **`prototype_elite_mini_boss`** (maxHp 200, attackDamage 25, telegraphWindupSeconds 2.5). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
|
||||
|
||||
Bruno scripts complement automated tests for manual dev-server verification. No `docs/manual-qa/NEO-90.md` (server-only per kickoff).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Linear blockedBy NEO-89** | Branch from **`main`** where NEO-89 is merged (confirmed at kickoff). | **adopted** |
|
||||
| **Optional fields later** | When content adds new optional behavior-def fields, bump **`schemaVersion`** or extend v1 in a follow-up issue. | **deferred** |
|
||||
| **Client not in this story** | Cross-link **NEO-97** / **NEO-98**; Bruno is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). | **adopted** |
|
||||
| **Numeric JSON types** | Emit radii/timings as JSON **number** (double); HP/damage as **integer**; tests assert numeric equality. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **`npcBehaviors`** top-level array key — matches content catalog envelope (user confirmed).
|
||||
- **No manual QA doc** — server-only HTTP; Bruno + automated tests (user confirmed).
|
||||
- **Registry-only injection** — HTTP layer depends on **`INpcBehaviorDefinitionRegistry`**, not **`NpcBehaviorDefinitionCatalog`**.
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`NpcBehaviorDefinitionsWorldApi`** + **`NpcBehaviorDefinitionsListDtos`** registered via **`MapNpcBehaviorDefinitionsWorldApi()`** in **`Program.cs`**; injects **`INpcBehaviorDefinitionRegistry`** only.
|
||||
- **`NpcBehaviorDefinitionsWorldApiTests`** — integration test for schema v1, frozen-three id order, **`prototype_melee_pressure`** / **`prototype_elite_mini_boss`** spot-checks.
|
||||
- **Bruno:** `bruno/neon-sprawl-server/npc-behavior-definitions/Get npc behavior definitions.bru` with response-shape tests.
|
||||
- **`server/README.md`**, **E5.M2** module doc, **alignment register**, and **E5M2 backlog** updated.
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Code review — NEO-44 gig XP on combat defeat
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-44-gig-xp-combat-defeat` · commits `30d379c`–`71a6761` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-44 delivers **E5M1-10**: when **`AbilityCastApi`** accepts a cast with **`combatResolution.targetDefeated === true`**, the server grants **25** gig XP to prototype main gig **`breach`** via **`CombatDefeatGigXpGrant`** and a dedicated **`IPlayerGigProgressionStore`** (in-memory + Postgres **`V007`**), explicitly **not** calling **`SkillProgressionGrantOperations`**. A read-only **`GET /game/players/{id}/gig-progression`** snapshot (NEO-37 gate pattern) exposes **`mainGigId`**, fixed **`level: 1`**, and accumulating **`xp`**. Integration tests cover GET 404/default, grant helper, defeat chain (4th pulse +25, 5th no delta, skill snapshot unchanged), and optional Postgres persistence; Bruno `gig-progression/` + **`docs/manual-qa/NEO-44.md`** complete verification. Implementation matches the kickoff plan and acceptance checklist. Residual risk is low — server-only slice; Godot gig row remains **NEO-86** out of scope.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-44-implementation-plan.md`](../plans/NEO-44-implementation-plan.md) | **Matches** — kickoff decisions (`breach`, 25 XP, GET-only wire, Postgres + in-memory), scope, expected outcomes table, reconciliation, and acceptance checklist align with code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-10** | **Matches** — AC checked; landed note and gig-vs-skill invariant documented. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Purpose/Responsibilities updated for gig XP via NEO-44 (not E2.M2 skill XP). |
|
||||
| [`docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md`](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) | **Matches** — NEO-44 landed paragraph added; removed from backlog list. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M2 and E5.M1 rows updated with NEO-44 landed + gig progression links. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-authoritative defeat grant; client polls GET when NEO-86 lands. |
|
||||
| [`docs/game-design/progression.md`](../game-design/progression.md) | **Matches** — combat → gig XP invariant referenced in README and manual QA. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — gig progression GET section, combat defeat invariant, ability-cast NEO-44 note. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents NEO-85/NEO-86 client counterpart; no Godot change in scope. |
|
||||
| [`docs/manual-qa/NEO-44.md`](../manual-qa/NEO-44.md) | **Matches** — curl defeat spine + skill control snapshot. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Stale E5.M1 Purpose / Responsibilities** — [`E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) line 17 still says combat “feeds XP awards through E2.M2”; line 26 says “Award combat XP via E2.M2 integration.” NEO-44 explicitly routes combat defeat to **`IPlayerGigProgressionStore`**. Update those bullets to “gig XP via dedicated store (NEO-44); not E2.M2 skill XP” so module intent matches the landed slice.~~ **Done.** Purpose, Responsibilities, and E2.M2 dependency bullets updated.
|
||||
|
||||
2. ~~**Stale E2.M2 implementation snapshot** — [`E2_M2_XpAwardAndLevelEngine.md`](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) **Still backlog** paragraph lists NEO-44; alignment register already marks NEO-44 landed. Add a **NEO-44 landed (E5.M1)** paragraph (cross-link E5.M1 / gig GET) and remove NEO-44 from backlog.~~ **Done.** NEO-44 landed paragraph added; backlog line trimmed to NEO-42 craft wiring only.
|
||||
|
||||
3. ~~**Best-effort grant observability (optional)** — `CombatDefeatGigXpGrant` discards **`TryApplyXpDelta`** return value per kickoff; acceptable for prototype. Consider a debug/diagnostic log when apply returns **`false`** on Postgres (unknown player, FK miss) before production hardening — cast accept should remain unchanged.~~ **Done.** `LogDebug` when store write fails; `AbilityCastApi` passes logger from `ILoggerFactory`.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Bruno **`Get gig progression after defeat spine.bru`** hardcodes **`dev-local-1`** in the GET URL while pre-request uses **`playerId`** env — use **`{{playerId}}`** (or env var) for consistency if multi-player Bruno runs matter.~~ **Done.** GET URL uses **`{{playerId}}`**.
|
||||
|
||||
- Nit: No automated test for **`prototype_target_beta`** defeat grant — plan adopts shared hook; alpha chain + integration test suffice for prototype; optional one-pulse-on-beta smoke if beta HP path diverges later.
|
||||
|
||||
- Nit: **`Get gig progression.bru`** asserts **`xp`** is a number but not **`0`** on fresh server — docs note Postgres may retain prior totals; intentional for shared DB dev; fine for CI in-memory.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~Gigs|FullyQualifiedName~PostAbilityCast_ShouldGrantGigXp"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~AbilityCastApiTests"
|
||||
```
|
||||
|
||||
**NEO-44 filtered tests:** 6/6 passed (local run).
|
||||
**AbilityCastApiTests:** 21/21 passed (local run).
|
||||
|
||||
**Postgres (optional):** `GigProgressionGrantPersistenceIntegrationTests` when **`ConnectionStrings__NeonSprawl`** is set.
|
||||
|
||||
**Bruno (manual):** Run `bruno/neon-sprawl-server/gig-progression/` — seq 1 fresh GET; seq 2 defeat spine (~13s pre-request) asserts **`breach.xp === 25`** and salvage/refine unchanged.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Code review — NEO-84 combat telemetry hook sites
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-84-combat-telemetry-hooks` · commits `fb2bfe5`–`667a1a9` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-84 delivers **E5M1-09**: comment-only telemetry hook sites in **`CombatOperations.TryResolve`** for Epic 5 Slice 1 catalog names **`ability_used`** (every successful resolve, including zero-damage abilities) and **`enemy_defeat`** (lethal defeat transition only), plus class-level reserved comments for **`encounter_start`** and **`player_death`**. All active sites carry **`TODO(E9.M1): catalog emit`** with planned payload fields; no runtime behavior, API shape, or test changes. **`server/README.md`**, **`CombatResult.cs`**, **`E5_M1_CombatRulesEngine.md`**, **`E5M1-prototype-backlog.md`**, and **`documentation_and_implementation_alignment.md`** were updated in the implementation commit. Implementation matches the kickoff plan and acceptance checklist. Residual risk is negligible — comments-only server slice; client combat HUD remains **NEO-85** out of scope.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-84-implementation-plan.md`](../plans/NEO-84-implementation-plan.md) | **Matches** — kickoff decisions (engine-only hooks, zero-damage `ability_used`, comments-only, skip manual QA), scope, hook placement table, and acceptance checklist align with code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-09** | **Matches** — AC checked; landed note cites **`TryResolve`** and README anchor. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status and **Related implementation slices** entry for E5M1-09 NEO-84. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row cites NEO-84 landed + README link. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-44 and NEO-84 landed (addressed post-review). |
|
||||
| [`docs/decomposition/epics/epic_05_pve_combat.md`](../decomposition/epics/epic_05_pve_combat.md) · Slice 1 telemetry vocabulary | **Matches** — hook names align with epic list. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-authoritative resolve path; no client telemetry. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — combat telemetry hooks subsection; distinguishes NEO-30 cast funnel from E5 combat-outcome names. |
|
||||
| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; NEO-85 owns HUD. |
|
||||
| Manual QA | **N/A** — kickoff skip (NEO-71/NEO-83 pattern). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Stale `module_dependency_register` E5.M1 note** — The long **E5.M1 note** paragraph still ends at **NEO-83 landed** and omits **NEO-44** (gig XP) and **NEO-84** (telemetry hooks) even though **`E5_M1_CombatRulesEngine.md`** and the alignment register were updated. Append landed one-liners (mirror alignment row) so dependency-register readers stay consistent.~~ **Done.** E5.M1 note appended NEO-44 + NEO-84 landed; E2.M2 note corrected NEO-44 status.
|
||||
|
||||
2. ~~**Damage-path `ability_used` payload comment parity** — The zero-damage branch documents that **`playerId`** is out of engine scope and should be correlated at **`AbilityCastApi`** accept; the post-damage **`ability_used`** block lists payload fields but omits that note. Copy the one-line **`playerId`** correlation hint for E9.M1 wiring consistency.~~ **Done.** Post-damage `ability_used` block now includes the `playerId` correlation line.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`enemy_defeat`** uses an empty `if (after.Defeated) { …comments… }` block — intentional hook anchoring (same pattern as **`gather_node_depleted`** in **`GatherOperations`**); fine as-is.
|
||||
|
||||
- Nit: Class-level reserved-site comments sit immediately above the **`TryResolve`** XML summary rather than in a dedicated region after all methods — readable and matches “class-level reserved block” kickoff decision; optional reorder only if a style guide emerges.
|
||||
|
||||
- ~~Nit: **`E2.M2 note`** in **`module_dependency_register`** still says **NEO-44 remains backlog** — pre-existing staleness unrelated to this diff; fix when touching register for suggestion 1.~~ **Done.** Addressed with suggestion 1 register update.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~CombatOperations"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~AbilityCastApiTests"
|
||||
```
|
||||
|
||||
**CombatOperationsTests:** 10/10 passed (local run).
|
||||
**AbilityCastApiTests:** 21/21 passed (local run).
|
||||
|
||||
**Manual (optional):** Confirm hook comment blocks in `server/NeonSprawl.Server/Game/Combat/CombatOperations.cs` cite **`ability_used`** / **`enemy_defeat`** / reserved names, include **`TODO(E9.M1)`**, and that **`enemy_defeat`** sits only after **`TryApplyDamage`** when **`after.Defeated`**. Optional Bruno `ability-cast/` + `combat-targets/` regression — no request/response change expected.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Code review — NEO-85 client combat feedback + target HP HUD
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-85-client-combat-feedback-hp-hud` · commits `f03e689`–`faf3fe1` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-85 delivers **E5M1-11**: the Godot client parses nested **`combatResolution`** on cast accept, extends **`CastFeedbackLabel`** with damage/defeat copy, adds **`CombatTargetsClient`** for **`GET /game/world/combat-targets`**, and wires **`CombatTargetHpLabel`** with event-driven refresh on successful cast and target lock change. Implementation follows NEO-28/NEO-73 thin-client patterns (injectable HTTP, `_busy` guard, harness tests) and keeps combat math server-authoritative. Fifteen new/extended GdUnit cases pass locally. Residual risk is low — client-only HUD wiring atop landed NEO-82/NEO-83 server contracts; brief HP label lag between cast feedback and GET completion is an adopted trade-off.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-85-implementation-plan.md`](../plans/NEO-85-implementation-plan.md) | **Matches** — kickoff decisions (event-driven refresh, extend `CastFeedbackLabel`, dedicated HP label), scope, acceptance checklist, and reconciliation align with code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-11** | **Matches** — AC checked; landed note cites clients + manual QA. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status line cites E5M1-11 NEO-85 landed; **Related implementation slices** includes **E5M1-11** entry. Done. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note cites **NEO-85 landed**; capstone **E5M1-12 NEO-86** pending. Done. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row cites NEO-85 landed + manual QA + client README. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — no client-side damage math; HP from server GET snapshot; cast feedback from wire fields only. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E1.M4** | **Partially matches** — NEO-85 extends **`ability_cast_client.gd`** / **`CastFeedbackLabel`** (E1.M4 cast funnel) but E1.M4 row stops at NEO-32; optional cross-reference only. |
|
||||
| [`client/README.md`](../../client/README.md) | **Matches** — combat HUD subsection documents cast resolution copy, HP label, refresh triggers, dev fixture reset. |
|
||||
| [`docs/manual-qa/NEO-85.md`](../manual-qa/NEO-85.md) | **Matches** — Godot steps cover lock → cast → HP stepping → defeat → deny → clear lock; regression bullets for NEO-28/NEO-32. |
|
||||
| Full-stack epic decomposition | **Matches** — client half of NEO-82/NEO-83 stack; manual QA is Godot-first (not Bruno-only). |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Stale `module_dependency_register` E5.M1 note** — Replace **Pending client: E5M1-11 NEO-85** with a **NEO-85 landed** one-liner (mirror the E5.M1 alignment row: `combat_targets_client.gd`, `CombatTargetHpLabel`, event-driven GET, manual QA link).~~ **Done.**
|
||||
|
||||
2. ~~**Missing E5M1-11 Related implementation slice** — Add an **E5M1-11 (NEO-85)** bullet under **Related implementation slices** in `E5_M1_CombatRulesEngine.md` (client combat HUD; link plan, manual QA, `client/README.md` combat section) so module readers stay consistent with E5M1-04–E5M1-10 entries.~~ **Done.**
|
||||
|
||||
3. ~~**HTTP error-path test gap** — Plan §Tests lists **404/invalid schema** failure for `combat_targets_client_test.gd`; implementation covers schema mismatch only. Add a mock **404** (or non-2xx) case asserting **`targets_sync_failed`** with **`HTTP 404`** for parity with other world GET clients.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`CombatRefreshHarness`** duplicates `_locked_target_id_from_state` / refresh logic from `main.gd` rather than testing `main.gd` directly — matches **`craft_feedback_refresh_test.gd`** precedent; acceptable for prototype scope.
|
||||
|
||||
- Nit: Clearing lock (Esc) still fires a combat-target GET even though the HP label already renders **no lock** — harmless extra HTTP; skip refresh when `new_lock` is empty if chatter matters later.
|
||||
|
||||
- Nit: Between cast accept and GET completion, **`CastFeedbackLabel`** shows authoritative **`targetRemainingHp`** from **`combatResolution`** while **`CombatTargetHpLabel`** may still show the prior snapshot row — adopted plan trade-off; optional future polish could render HP from resolution until GET lands.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/client
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
|
||||
-a res://test/ability_cast_client_test.gd \
|
||||
-a res://test/combat_targets_client_test.gd \
|
||||
-a res://test/combat_feedback_refresh_test.gd
|
||||
```
|
||||
|
||||
**GdUnit (local run):** 16/16 passed (8 + 5 + 3 suites).
|
||||
|
||||
**Manual:** [`docs/manual-qa/NEO-85.md`](../manual-qa/NEO-85.md) — server + Godot; Tab lock **`prototype_target_alpha`**, digit cast **`prototype_pulse`**, verify cast resolution copy and HP label stepping through defeat; optional **`POST /game/__dev/combat-targets-fixture`** between runs.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Code review — NEO-86 playable tab-target combat capstone (Godot)
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-86-playable-combat-capstone` · commits `8190614`–`b97cace` vs `origin/main`
|
||||
**Follow-up:** Code review suggestions 1–4 addressed (alignment **Ready**, README link, two GdUnit cases).
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-86 closes **E5M1-12** and Epic 5 Slice 1 client acceptance: a capstone manual QA script, README integration checklist, **`GigProgressionClient`** mirroring **`SkillProgressionClient`**, **`GigXpLabel`** under the economy HUD, and defeat-triggered **`GET …/gig-progression`** refresh wired from **`_on_cast_result_received`**. No server changes; gig XP remains server-authoritative (NEO-44 grant + GET reconcile). Five new GdUnit cases pass locally. Residual risk is low — thin-client wiring atop NEO-85 combat HUD; brief lag between defeat cast feedback and gig label update until GET completes is an adopted trade-off (same pattern as combat-target refresh).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-86-implementation-plan.md`](../plans/NEO-86-implementation-plan.md) | **Matches** — kickoff decisions (Godot gig row, docs-first + gap-fill, fresh-server baseline), scope, acceptance checklist, and reconciliation align with shipped code. |
|
||||
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-12** | **Matches** — AC checked; landed note cites gig client, manual QA, README. |
|
||||
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status **Ready**; **E5M1-12 (NEO-86)** slice added; Slice 1 client capstone complete. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note cites **NEO-86 landed** + capstone manual QA link. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M1** | **Matches** — row updated with NEO-86 landed + Slice 1 client capstone note; **Status** **Ready** (aligned with `E5_M1_CombatRulesEngine.md`). |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — gig XP from server GET only; defeat refresh triggered by wire **`targetDefeated`**, not client-side XP math. |
|
||||
| [`client/README.md`](../../client/README.md) | **Matches** — end-to-end combat loop section and test scope list; NEO-85 cross-link uses `../docs/manual-qa/NEO-85.md`. |
|
||||
| [`docs/manual-qa/NEO-86.md`](../manual-qa/NEO-86.md) | **Matches** — single-session Godot script, combat math table, economy HUD toggle step, no Bruno/curl; acceptance boxes intentionally unchecked pre-human QA. |
|
||||
| Full-stack epic decomposition | **Matches** — client capstone with Godot manual QA; not Bruno-only prototype proof. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**E5.M1 alignment status vs module doc** — `E5_M1_CombatRulesEngine.md` header is **Ready** with E5M1-01–12 landed; `documentation_and_implementation_alignment.md` E5.M1 row still **In Progress**. Promote to **Ready** (or add a one-line note why server slice remains in progress) so register, module page, and alignment table stay consistent.~~ **Done.** E5.M1 **Status** column promoted to **Ready**.
|
||||
|
||||
2. ~~**Broken NEO-85 README link** — In `client/README.md` § End-to-end combat loop, `[NEO-85](NEO-85.md)` resolves relative to `client/` and 404s. Use `[NEO-85](../docs/manual-qa/NEO-85.md)` like the NEO-28/NEO-31 links in the same line.~~ **Done.**
|
||||
|
||||
3. ~~**Schema-mismatch HTTP test gap** — Plan §Tests lists invalid schema → **`progression_sync_failed`** for `gig_progression_client_test.gd`; implementation covers parse unit test + 404 only. Add a mock 200 with `schemaVersion: 2` (or missing `mainGigId`) asserting **`progression_sync_failed`** with the schema mismatch reason — parity with `combat_targets_client_test.gd` **`test_invalid_schema_emits_sync_failed`**.~~ **Done.** `test_invalid_schema_emits_progression_sync_failed`.
|
||||
|
||||
4. ~~**Deny-path refresh test gap** — Plan §Tests lists deny cast must not trigger gig sync; `gig_feedback_refresh_test.gd` covers defeat accept and non-defeat accept only. Add a third case with `accepted: false` and assert **`gig_sync_calls == 0`**.~~ **Done.** `test_denied_cast_skips_gig_progression_sync`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`GigRefreshHarness`** duplicates defeat-gated refresh logic from `main.gd` rather than testing `main.gd` directly — matches **`combat_feedback_refresh_test.gd`** / **`craft_feedback_refresh_test.gd`** precedent; acceptable for prototype scope.
|
||||
|
||||
- Nit: Plan describes **`gig_row`** using a cached snapshot when the second arg is omitted; client has no internal cache — `main.gd` passes **`_last_gig_snapshot`** explicitly. Behavior is correct; plan wording could note cache lives in `main.gd`.
|
||||
|
||||
- Nit: Between defeat cast accept and gig GET completion, **`CastFeedbackLabel`** shows authoritative defeat copy while **`GigXpLabel`** may still show pre-defeat XP until GET lands — same event-driven lag as combat HP label; optional future polish could show interim copy from cast resolution if UX matters.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/client
|
||||
godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \
|
||||
-a res://test/gig_progression_client_test.gd \
|
||||
-a res://test/gig_feedback_refresh_test.gd
|
||||
```
|
||||
|
||||
**GdUnit (local run):** 7/7 passed (4 + 3 suites).
|
||||
|
||||
**Manual:** [`docs/manual-qa/NEO-86.md`](../manual-qa/NEO-86.md) — fresh server restart, Godot **F5**, Tab lock **`prototype_target_alpha`**, four **`prototype_pulse`** casts, verify **`GigXpLabel`** **`breach: L1 · 25 xp`**, deny on defeated target, economy HUD collapse step 9.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Code review — NEO-87 prototype NpcBehaviorDef catalog + CI
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-87-e5m2-npc-behavior-catalog-schemas-ci` · commits `846da90`–`6fd9694` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Restored missing `prototype_elite_mini_boss` row; `validate_content.py` passes with 3 npc behavior ids.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-87 lands E5M2-01 content-only work: `npc-behavior-def.schema.json`, NPC behavior validation in `scripts/validate_content.py` (mirroring NEO-76 ability pattern), and thorough doc updates (E5.M2 freeze table, `content/README.md`, CT.M1, alignment register, backlog checkboxes). Schema shape, CI gate constants, and Python validation helpers align with the kickoff plan. **Follow-up:** the initial commit shipped a two-row catalog (elite row dropped before commit); restored per freeze table — CI now passes with all three ids.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-87-implementation-plan.md`](../plans/NEO-87-implementation-plan.md) | **Matches** — reconciliation and AC align with three-row catalog after follow-up fix. |
|
||||
| [`docs/plans/E5M2-prototype-backlog.md`](../plans/E5M2-prototype-backlog.md) · **E5M2-01** | **Matches** — AC and “Landed” note accurate once catalog is complete. |
|
||||
| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Matches** — freeze table, catalog file, and CI rules line aligned. |
|
||||
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — NPC behavior PR gate paragraph added. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M2** | **Matches** — **NEO-87 landed** accurate after catalog fix. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M2 note cites **NEO-87 landed** with passing CI. |
|
||||
| [`content/README.md`](../../content/README.md) | **Matches** — `npc-behaviors/` row + E5 Slice 2 freeze paragraph (ids and rules). |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — no runtime authority changes; content-only. |
|
||||
| Full-stack epic decomposition | **Matches** — E5M2-01 is **server/content + CI** only; kickoff explicitly defers Godot to E5M2-04+ / NEO-97 / NEO-98. No `docs/manual-qa/NEO-87.md` required. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
1. ~~**Missing third catalog row — CI fails.** [`content/npc-behaviors/prototype_npc_behaviors.json`](../../content/npc-behaviors/prototype_npc_behaviors.json) contains only `prototype_melee_pressure` and `prototype_ranged_control`. The E5M2 three-id gate requires `prototype_elite_mini_boss` as well. Running validation:~~ **Done.** Restored `prototype_elite_mini_boss` row per kickoff freeze table; `validate_content.py` exits 0 with `3 unique npc behavior id(s)` (`24cbd9b`).
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Defer “landed” doc claims until CI is green** — After fixing the catalog, re-run validation and confirm plan reconciliation, E5M2-01 backlog “Landed” note, alignment register, and module register accurately reflect a passing gate (same pattern as [NEO-76 review follow-up](../reviews/2026-05-24-NEO-76.md)).~~ **Done.** Validation green; existing doc claims now accurate — no further doc edits required.
|
||||
|
||||
2. **Optional: one-row-per-`archetypeKind` gate** — The three-id frozenset already implies one row per archetype for E5M2-01. If the roster grows in a follow-up issue, consider an explicit archetype coverage check; not required for this slice.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: Commit subject “ship prototype … catalog” reads complete while the catalog file is incomplete — minor messaging; resolved when elite row lands.~~ **Done.** Elite row restored in follow-up commit.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# From repo root
|
||||
python3 -m venv .venv-content && .venv-content/bin/pip install -r scripts/requirements-content.txt
|
||||
.venv-content/bin/python scripts/validate_content.py
|
||||
# Expect: content OK … 1 npc behavior catalog file(s) … 3 unique npc behavior id(s)
|
||||
|
||||
# Negative smokes (optional, after fix)
|
||||
# — duplicate id across rows → non-zero exit
|
||||
# — remove one frozen id → gate error
|
||||
# — leashRadius <= aggroRadius → numeric gate error
|
||||
```
|
||||
|
||||
PR gate (`.github/workflows/pr-gate.yml`) runs the same script; no additional workflow changes needed for this story.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Code review — NEO-88 server NPC behavior catalog load (fail-fast)
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-88-e5m2-server-npc-behavior-catalog-load` · commits `7730bde`–`c56ad22` vs `origin/main`
|
||||
**Follow-up:** Doc updates per suggestions 1–2 (module register E5.M2 note + E5.M2 freeze Rules paragraph).
|
||||
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-88 delivers **E5M2-02**: fail-fast startup load of `content/npc-behaviors/*_npc_behaviors.json` under `server/NeonSprawl.Server/Game/Npc/`, mirroring the NEO-77 ability-catalog pattern. The loader validates Draft 2020-12 row schema, `schemaVersion`, duplicate `id`, the frozen E5M2 three-id gate (`PrototypeE5M2NpcBehaviorCatalogRules`), and the `leashRadius > aggroRadius` numeric gate; registers `NpcBehaviorDefinitionCatalog` in DI; and eagerly resolves it in `Program.cs`. Sixteen AAA loader/host tests pass. Docs (plan, E5M2 backlog, E5.M2 module status, alignment register, `server/README.md`) are updated. Bruno adds a health smoke request with `folder.bru`. `INpcBehaviorDefinitionRegistry` is correctly deferred to NEO-89. No client or NPC HTTP API — appropriately scoped. Risk is low: infrastructure-only; CI already gates content via NEO-87.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-88-implementation-plan.md`](../plans/NEO-88-implementation-plan.md) | **Matches** — kickoff decisions, loader/catalog/DI scope, acceptance checklist, reconciliation section; registry correctly out of scope. |
|
||||
| [`docs/plans/E5M2-prototype-backlog.md`](../plans/E5M2-prototype-backlog.md) · **E5M2-02** | **Matches** — AC checkboxes + landed note. |
|
||||
| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Matches** — Status line cites NEO-88 server load; freeze **Rules** cites CI + server startup (NEO-88). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M2** | **Matches** — NEO-87 + NEO-88 landed notes; next backlog item NEO-89. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M2 **Status** **In Progress**; **E5.M2 note** cites NEO-87 + NEO-88 server load. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server startup catalog only; no client mutation or NPC runtime. |
|
||||
| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches** — C# loader mirrors `_validate_npc_behavior_catalogs` + E5M2 id/numeric gates; sync comment on `PrototypeE5M2NpcBehaviorCatalogRules.ExpectedBehaviorIds`. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — NPC behavior catalog section (config keys, discovery, fail-fast, E5M2 parity, NEO-89 registry deferral). |
|
||||
| Full-stack epic decomposition | **Matches** — E5M2-02 is **server/content** only; kickoff defers Godot to E5M2-04+ / NEO-98; no false “prototype complete” claim. |
|
||||
| Manual QA | **N/A** — plan documents skip rationale: automated loader/host tests + Bruno health smoke. |
|
||||
|
||||
Register/tracking: alignment table E5.M2 **In Progress** with NEO-88 note is correct; module register **E5.M2 note** updated with NEO-88.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Update `module_dependency_register.md` E5.M2 note** — Append **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` with link to [NEO-88 plan](../plans/NEO-88-implementation-plan.md) and [server README — NPC behavior catalog](../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88) (same pattern as E5.M1 note after NEO-77).~~ **Done.** E5.M2 note extended in `module_dependency_register.md`.
|
||||
|
||||
2. ~~**E5.M2 freeze Rules paragraph** — In [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md), extend the CI sentence to cite server startup enforcement via NEO-88 (mirror E5.M1 freeze rules referencing NEO-77).~~ **Done.** Rules paragraph now cites CI + server startup (NEO-88).
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: `ValidPrototypeCatalogJson` duplicates `content/npc-behaviors/prototype_npc_behaviors.json` inline — fine for isolated temp-dir fixtures (same pattern as NEO-77 ability tests); optional future refactor to copy repo file.
|
||||
|
||||
- Nit: E5M2 gate error string format differs slightly from Python (`'prototype_melee_pressure', …` vs `sorted(...)!r`); behavior is equivalent and messages are actionable.
|
||||
|
||||
- Nit: Repeated `services.AddOptions<ContentPathsOptions>().Bind(...)` in each catalog extension — established repo pattern (NEO-51/NEO-66/NEO-77); harmless duplicate binds.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~NpcBehaviorDefinitionCatalogLoaderTests"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
|
||||
# Content CI parity (repo root)
|
||||
python3 scripts/validate_content.py
|
||||
# Expect: … 1 npc behavior catalog file(s) … 3 unique npc behavior id(s)
|
||||
```
|
||||
|
||||
Bruno: `bruno/neon-sprawl-server/npc-behavior-catalog/` — GET `/health` smoke after catalog boot.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Code review — NEO-89 injectable `INpcBehaviorDefinitionRegistry` + DI
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-89-inpcbehaviordefinitionregistry-di` · commits `7e4e0ca`–`e25b140` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-89 delivers **E5M2-03**: a thin **`INpcBehaviorDefinitionRegistry`** adapter over the NEO-88 startup-loaded **`NpcBehaviorDefinitionCatalog`**, registered in **`AddNpcBehaviorDefinitionCatalog`** alongside the catalog singleton. The implementation mirrors **`AbilityDefinitionRegistry`** (NEO-79): exact-id **`TryGetDefinition`**, trim/lowercase **`TryNormalizeKnown`**, precomputed **`GetDefinitionsInIdOrder`**, and **`PrototypeNpcBehaviorRegistry`** id constants for tests. Eleven new AAA unit/host tests pass; **27** Npc-filter tests green including loader regression. Docs (plan, E5M2 backlog E5M2-03, alignment register, module register, E5.M2 status, `server/README.md`) are reconciled. No HTTP, runtime, or client surface — appropriately scoped for a DI-only story. Risk is low: read-only adapter with no behavior change to catalog load semantics.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-89-implementation-plan.md`](../plans/NEO-89-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation (11 tests, DI pattern, no `Program.cs` eager registry resolve). |
|
||||
| [`docs/plans/E5M2-prototype-backlog.md`](../plans/E5M2-prototype-backlog.md) · **E5M2-03** | **Matches** — AC checkboxes + landed note. |
|
||||
| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Matches** — Status/Linear rows cite NEO-89; freeze **Rules** + NEO-88/NEO-89 implementation blurbs (review follow-up). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M2** | **Matches** — NEO-89 landed note added. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M2 note cites NEO-89 registry + DI. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only read registry; no runtime or client mutation. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — registry methods documented; catalog reserved for fail-fast boot; `PrototypeNpcBehaviorRegistry` noted. |
|
||||
| [`server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs) (pattern reference) | **Matches** — NPC registry is structurally identical to NEO-79 precedent. |
|
||||
| Full-stack epic decomposition | **Matches** — E5M2-03 is server-only; kickoff defers player-visible HTTP to NEO-90 and Godot to NEO-98; no false prototype-complete claim. |
|
||||
| Manual QA | **N/A** — plan documents skip: automated unit/host tests only; Bruno proof deferred to NEO-90. |
|
||||
|
||||
Register/tracking: alignment table and module register updates are correct; E5.M2 remains **In Progress** toward NEO-90+.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**E5.M2 freeze Rules paragraph** — In [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md), extend the **Rules** sentence to cite **`INpcBehaviorDefinitionRegistry`** + NEO-89 as the preferred game lookup path (mirror E5.M1 freeze rules citing **`IAbilityDefinitionRegistry`** / NEO-79 after NEO-77 server load).~~ **Done.**
|
||||
|
||||
2. ~~**Optional E5.M2 implementation blurb** — Add a short “NPC behavior definition registry (NEO-89)” paragraph under the module doc (same style as E3.M3’s **`IItemDefinitionRegistry`** section) so future NEO-90+ stories have a single anchor for the contract without rereading the plan.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: No dedicated unit test that **`TryGetDefinition`** rejects case-variant ids (e.g. `"Prototype_Melee_Pressure"`) — fail-closed by design and covered indirectly via **`TryNormalizeKnown`**; optional explicit test if NEO-90 HTTP validation wants to document the split.~~ **Done.**
|
||||
|
||||
- Nit: **`TryGetDefinition`** for **`prototype_ranged_control`** metadata is only asserted in the loader-fixture and host tests, not a standalone unit test like melee/elite — acceptable given loader coverage.
|
||||
|
||||
- Nit: Inline **`MeleeRow`/`RangedRow`/`EliteRow`** test helpers duplicate freeze-box numbers — same established pattern as ability registry tests.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~NpcBehavior"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
Confirmed during review: **27** Npc-filter tests passed.
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Code review — NEO-90 GET `/game/world/npc-behavior-definitions`
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Scope:** Branch `NEO-90-npc-behavior-definitions-get` · commits `c757b7a`–`d3e765f` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-90 delivers **E5M2-04**: a read-only **`GET /game/world/npc-behavior-definitions`** projection backed solely by **`INpcBehaviorDefinitionRegistry`**, mirroring the NEO-78 ability-definitions pattern. Implementation adds `NpcBehaviorDefinitionsWorldApi`, versioned DTOs (`schemaVersion` **1**, **`npcBehaviors`**), Bruno smokes, integration test, and reconciled plan/backlog/module/alignment docs. The HTTP layer maps all nine schema fields from `GetDefinitionsInIdOrder()` without duplicating catalog truth. One new AAA integration test passes; **29** Npc-filter tests green. No runtime, instance registry, or client surface — appropriately scoped for a server-only HTTP read model ahead of NEO-97/NEO-98. Risk is low.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-90-implementation-plan.md`](../plans/NEO-90-implementation-plan.md) | **Matches** — kickoff decisions (`npcBehaviors` key, no manual QA), scope, acceptance checklist, reconciliation section. |
|
||||
| [`docs/plans/E5M2-prototype-backlog.md`](../plans/E5M2-prototype-backlog.md) · **E5M2-04** | **Matches** — AC checkboxes + landed note. |
|
||||
| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Matches** — Status, Related slices, NEO-90 implementation blurb, **Linear** summary row, and freeze **Rules** cross-link updated (review follow-up). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M2** | **Matches** — NEO-90 landed note with README + Bruno links. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) · **E5.M2 note** | **Matches** — NEO-90 HTTP projection note added (review follow-up). |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — read-only world definition GET; no runtime state or client mutation. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — NPC behavior definitions section with curl example and cross-links. |
|
||||
| [`server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs) (pattern reference) | **Matches** — structurally identical registry → DTO → `Results.Json` mapping. |
|
||||
| Full-stack epic decomposition | **Matches** — server-only; kickoff defers Godot labels to NEO-97/NEO-98; no false prototype-complete claim. |
|
||||
| Manual QA | **N/A** — plan documents skip: Bruno + integration tests only. |
|
||||
|
||||
Register/tracking: alignment table E5.M2 **In Progress** with NEO-90 note is correct; module register **E5.M2 note** extended (review follow-up).
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Update `module_dependency_register.md` E5.M2 note** — Append **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` with link to [NEO-90 plan](../plans/NEO-90-implementation-plan.md), [server README — NPC behavior definitions (NEO-90)](../../server/README.md#npc-behavior-definitions-neo-90), and Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/` (same pattern as E5.M1 note after NEO-78).~~ **Done.**
|
||||
|
||||
2. ~~**E5.M2 Linear summary row** — In [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md), extend the **Linear** field (Summary table line 11) to cite **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) so it matches the updated **Status** row and the decomposed backlog table below.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: Neither C# nor Bruno tests assert **`prototype_ranged_control`** row metadata — acceptable per plan (melee + elite spot-checks only); optional ranged row assertion if NEO-97 client labels want a third anchor.
|
||||
|
||||
- Nit: **`FrozenThreeInIdOrder`** in tests uses **`PrototypeNpcBehaviorRegistry`** constants (good); Bruno hard-codes string literals — same established split as ability-definitions Bruno vs C# tests.
|
||||
|
||||
- ~~Nit: Freeze **Rules** paragraph cites registry injection (NEO-89) but not the NEO-90 GET endpoint by name — the new NEO-90 blurb below covers it; optional one-line cross-link in Rules for skimmers.~~ **Done.**
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /home/don/neon-sprawl/server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~NpcBehaviorDefinitions"
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~NpcBehavior"
|
||||
```
|
||||
|
||||
Confirmed during review: **1** NpcBehaviorDefinitions test passed; **29** Npc-filter tests passed.
|
||||
|
||||
Optional: run Bruno `Get npc behavior definitions.bru` against a dev server (`curl` example in `server/README.md`).
|
||||
|
|
@ -11,6 +11,7 @@ Validates:
|
|||
- resource yield catalogs: content/resource-nodes/*_resource_yields.json vs resource-yield-row.schema.json (NEO-57)
|
||||
- recipe catalogs: content/recipes/*_recipes.json rows vs content/schemas/recipe-def.schema.json (NEO-65)
|
||||
- ability catalogs: content/abilities/*_abilities.json rows vs content/schemas/ability-def.schema.json (NEO-76)
|
||||
- npc behavior catalogs: content/npc-behaviors/*_npc_behaviors.json rows vs content/schemas/npc-behavior-def.schema.json (NEO-87)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -32,12 +33,14 @@ RESOURCE_YIELD_ROW_SCHEMA = REPO_ROOT / "content/schemas/resource-yield-row.sche
|
|||
RECIPE_IO_ROW_SCHEMA = REPO_ROOT / "content/schemas/recipe-io-row.schema.json"
|
||||
RECIPE_SCHEMA = REPO_ROOT / "content/schemas/recipe-def.schema.json"
|
||||
ABILITY_SCHEMA = REPO_ROOT / "content/schemas/ability-def.schema.json"
|
||||
NPC_BEHAVIOR_SCHEMA = REPO_ROOT / "content/schemas/npc-behavior-def.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes"
|
||||
RECIPES_DIR = REPO_ROOT / "content/recipes"
|
||||
ABILITIES_DIR = REPO_ROOT / "content/abilities"
|
||||
NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
|
||||
|
||||
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
|
@ -102,6 +105,16 @@ PROTOTYPE_E5M1_ABILITY_IDS = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
# Epic 5 Slice 2 prototype lock (NEO-87): exact npc behavior ids after schema passes.
|
||||
# Keep in sync with E5.M2 freeze table and future NEO-88 server loader.
|
||||
PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
|
||||
{
|
||||
"prototype_melee_pressure",
|
||||
"prototype_ranged_control",
|
||||
"prototype_elite_mini_boss",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
||||
|
|
@ -469,6 +482,79 @@ def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _validate_npc_behavior_catalogs(
|
||||
*,
|
||||
npc_behavior_files: list[Path],
|
||||
npc_behavior_validator: Draft202012Validator,
|
||||
) -> tuple[int, dict[str, str], dict[str, dict]]:
|
||||
"""Validate NPC behavior JSON files. Returns (error_count, seen_ids, id_to_row)."""
|
||||
errors = 0
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_row: dict[str, dict] = {}
|
||||
|
||||
for path in npc_behavior_files:
|
||||
rel = str(path.relative_to(REPO_ROOT))
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
schema_version = data.get("schemaVersion")
|
||||
if schema_version != 1:
|
||||
print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
npc_behaviors = data.get("npcBehaviors")
|
||||
if not isinstance(npc_behaviors, list):
|
||||
print(f"error: {rel}: expected top-level 'npcBehaviors' array", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
for i, row in enumerate(npc_behaviors):
|
||||
if not isinstance(row, dict):
|
||||
print(f"error: {rel}: npcBehaviors[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(npc_behavior_validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(f"error: {rel} npcBehaviors[{i}] {loc}: {err.message}", file=sys.stderr)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
bid = row.get("id")
|
||||
if isinstance(bid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(bid)
|
||||
if prev:
|
||||
print(f"error: duplicate npc behavior id {bid!r} in {prev} and {rel}", file=sys.stderr)
|
||||
errors += 1
|
||||
else:
|
||||
seen_ids[bid] = rel
|
||||
id_to_row[bid] = row
|
||||
|
||||
return errors, seen_ids, id_to_row
|
||||
|
||||
|
||||
def _prototype_e5m2_npc_behavior_gate(seen_ids: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if E5M2 npc behavior contract fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS:
|
||||
return (
|
||||
"error: prototype E5M2 expects exactly npc behavior ids "
|
||||
f"{sorted(PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e5m2_npc_behavior_numeric_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if leash/aggro invariants fail, else None."""
|
||||
for bid, row in id_to_row.items():
|
||||
aggro = row.get("aggroRadius")
|
||||
leash = row.get("leashRadius")
|
||||
if isinstance(aggro, (int, float)) and isinstance(leash, (int, float)):
|
||||
if not leash > aggro:
|
||||
return (
|
||||
f"error: npc behavior {bid!r}: leashRadius {leash} must be > aggroRadius {aggro}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
||||
if len(track_skill_ids) != 1:
|
||||
|
|
@ -792,6 +878,9 @@ def main() -> int:
|
|||
if not ABILITY_SCHEMA.is_file():
|
||||
print(f"error: missing schema {ABILITY_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not NPC_BEHAVIOR_SCHEMA.is_file():
|
||||
print(f"error: missing schema {NPC_BEHAVIOR_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_validator = Draft202012Validator(skill_schema)
|
||||
|
|
@ -808,6 +897,8 @@ def main() -> int:
|
|||
recipe_validator = _recipe_def_validator()
|
||||
ability_schema = json.loads(ABILITY_SCHEMA.read_text(encoding="utf-8"))
|
||||
ability_validator = Draft202012Validator(ability_schema)
|
||||
npc_behavior_schema = json.loads(NPC_BEHAVIOR_SCHEMA.read_text(encoding="utf-8"))
|
||||
npc_behavior_validator = Draft202012Validator(npc_behavior_schema)
|
||||
|
||||
if not SKILLS_DIR.is_dir():
|
||||
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
|
||||
|
|
@ -873,6 +964,15 @@ def main() -> int:
|
|||
print(f"error: no *_abilities.json files under {ABILITIES_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not NPC_BEHAVIORS_DIR.is_dir():
|
||||
print(f"error: missing directory {NPC_BEHAVIORS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
npc_behavior_files = sorted(NPC_BEHAVIORS_DIR.glob("*_npc_behaviors.json"))
|
||||
if not npc_behavior_files:
|
||||
print(f"error: no *_npc_behaviors.json files under {NPC_BEHAVIORS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
errors = 0
|
||||
|
|
@ -1074,6 +1174,24 @@ def main() -> int:
|
|||
print(e5m1_ability_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
npc_behavior_errors, npc_behavior_seen_ids, npc_behavior_id_to_row = _validate_npc_behavior_catalogs(
|
||||
npc_behavior_files=npc_behavior_files,
|
||||
npc_behavior_validator=npc_behavior_validator,
|
||||
)
|
||||
if npc_behavior_errors:
|
||||
print(f"content validation failed with {npc_behavior_errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e5m2_npc_behavior_err = _prototype_e5m2_npc_behavior_gate(npc_behavior_seen_ids)
|
||||
if e5m2_npc_behavior_err:
|
||||
print(e5m2_npc_behavior_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e5m2_npc_behavior_numeric_err = _prototype_e5m2_npc_behavior_numeric_gate(npc_behavior_id_to_row)
|
||||
if e5m2_npc_behavior_numeric_err:
|
||||
print(e5m2_npc_behavior_numeric_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"content OK: "
|
||||
f"{len(skill_files)} skill catalog file(s), "
|
||||
|
|
@ -1084,11 +1202,13 @@ def main() -> int:
|
|||
f"{len(yield_files)} resource yield catalog file(s), "
|
||||
f"{len(recipe_files)} recipe catalog file(s), "
|
||||
f"{len(ability_files)} ability catalog file(s), "
|
||||
f"{len(npc_behavior_files)} npc behavior catalog file(s), "
|
||||
f"{len(seen_ids)} unique skill id(s), "
|
||||
f"{len(item_seen_ids)} unique item id(s), "
|
||||
f"{len(node_seen_ids)} unique nodeDefId(s), "
|
||||
f"{len(recipe_seen_ids)} unique recipe id(s), "
|
||||
f"{len(ability_seen_ids)} unique ability id(s), "
|
||||
f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
|
||||
f"{len(track_skill_ids)} mastery track(s)"
|
||||
)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ using System.Net.Http.Json;
|
|||
using System.Text;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -626,6 +628,55 @@ public sealed class AbilityCastApiTests
|
|||
Assert.Null(fifth.CombatResolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
Assert.NotNull(factory.FakeClock);
|
||||
await BindSlot0PulseAsync(client);
|
||||
await LockPrototypeTargetAlphaAsync(client);
|
||||
var skillBefore = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/skill-progression");
|
||||
Assert.NotNull(skillBefore);
|
||||
var salvageBefore = skillBefore!.Skills!.Single(static s => s.Id == "salvage");
|
||||
var refineBefore = skillBefore.Skills!.Single(static s => s.Id == "refine");
|
||||
var cast = PulseCastRequest();
|
||||
|
||||
// Act
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
response.EnsureSuccessStatusCode();
|
||||
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
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);
|
||||
var gigSnapshot = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/gig-progression");
|
||||
var skillAfter = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/skill-progression");
|
||||
|
||||
// Assert
|
||||
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.NotNull(fourth);
|
||||
Assert.True(fourth!.CombatResolution!.TargetDefeated);
|
||||
var fifth = await fifthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
|
||||
Assert.NotNull(fifth);
|
||||
Assert.False(fifth!.Accepted);
|
||||
Assert.NotNull(gigSnapshot);
|
||||
var breach = Assert.Single(gigSnapshot!.Gigs!);
|
||||
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, breach.Xp);
|
||||
Assert.NotNull(skillAfter);
|
||||
var salvageAfter = skillAfter!.Skills!.Single(static s => s.Id == "salvage");
|
||||
var refineAfter = skillAfter.Skills!.Single(static s => s.Id == "refine");
|
||||
Assert.Equal(salvageBefore.Xp, salvageAfter.Xp);
|
||||
Assert.Equal(refineBefore.Xp, refineAfter.Xp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAbilityCast_ShouldUseCatalogCooldown_WhenGuardCooldownLongerThanPulse()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Gigs;
|
||||
|
||||
/// <summary>NEO-44: combat defeat hook → <c>breach</c> gig XP via dedicated store (not E2.M2).</summary>
|
||||
public sealed class CombatDefeatGigXpGrantTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GrantOnCombatDefeat_WhenPlayerKnown_ShouldIncreaseBreachXp()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var gigStore = scope.ServiceProvider.GetRequiredService<IPlayerGigProgressionStore>();
|
||||
|
||||
// Act
|
||||
CombatDefeatGigXpGrant.GrantOnCombatDefeat("dev-local-1", gigStore);
|
||||
|
||||
// Assert
|
||||
var totals = gigStore.GetXpTotals("dev-local-1");
|
||||
Assert.True(totals.TryGetValue(GigProgressionConstants.PrototypeMainGigId, out var xp));
|
||||
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, xp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantOnCombatDefeat_WhenPlayerUnknown_ShouldNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var gigStore = scope.ServiceProvider.GetRequiredService<IPlayerGigProgressionStore>();
|
||||
|
||||
// Act
|
||||
var exception = Record.Exception(() =>
|
||||
CombatDefeatGigXpGrant.GrantOnCombatDefeat("missing-player", gigStore));
|
||||
|
||||
// Assert
|
||||
Assert.Null(exception);
|
||||
Assert.Empty(gigStore.GetXpTotals("missing-player"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GrantOnCombatDefeat_WhenPlayerUnknown_ShouldNotThrow_WithLogger()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var gigStore = scope.ServiceProvider.GetRequiredService<IPlayerGigProgressionStore>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(nameof(CombatDefeatGigXpGrant));
|
||||
|
||||
// Act
|
||||
var exception = Record.Exception(() =>
|
||||
CombatDefeatGigXpGrant.GrantOnCombatDefeat("missing-player", gigStore, logger));
|
||||
|
||||
// Assert
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
using System.Linq;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||
using Npgsql;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Gigs;
|
||||
|
||||
[Collection("Postgres integration")]
|
||||
public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||
{
|
||||
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||
|
||||
[RequirePostgresFact]
|
||||
public async Task DefeatGrantThenGetAcrossNewFactory_ShouldPersistGigXp()
|
||||
{
|
||||
// Arrange
|
||||
await ResetGigProgressionTableAsync();
|
||||
var cast = new AbilityCastRequest
|
||||
{
|
||||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
};
|
||||
|
||||
// Act — defeat alpha through first host (four pulses)
|
||||
using (var firstClient = Factory.CreateClient())
|
||||
{
|
||||
await firstClient.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/hotbar-loadout",
|
||||
new HotbarLoadoutUpdateRequest
|
||||
{
|
||||
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
|
||||
});
|
||||
await firstClient.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
});
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var response = await firstClient.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (i < 3)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(3.2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await using var secondFactory = new PostgresWebApplicationFactory();
|
||||
using var secondClient = secondFactory.CreateClient();
|
||||
var snapshot = await secondClient.GetFromJsonAsync<GigProgressionSnapshotResponse>(
|
||||
"/game/players/dev-local-1/gig-progression");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(snapshot);
|
||||
var breach = Assert.Single(snapshot!.Gigs!);
|
||||
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount, breach.Xp);
|
||||
}
|
||||
|
||||
private async Task ResetGigProgressionTableAsync()
|
||||
{
|
||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||
}
|
||||
|
||||
_ = Factory.Services;
|
||||
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||
|
||||
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||
var gigDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V007__player_gig_progression.sql");
|
||||
if (!File.Exists(positionDdlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Test DDL not found at '{positionDdlPath}'.", positionDdlPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(gigDdlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Test DDL not found at '{gigDdlPath}'.", gigDdlPath);
|
||||
}
|
||||
|
||||
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||
var gigDdl = await File.ReadAllTextAsync(gigDdlPath);
|
||||
await using var conn = new NpgsqlConnection(cs);
|
||||
await conn.OpenAsync();
|
||||
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||
{
|
||||
await applyPosition.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||
{
|
||||
await truncate.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||
|
||||
await using (var applyGig = new NpgsqlCommand(gigDdl, conn))
|
||||
{
|
||||
await applyGig.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
await using (var truncateGig = new NpgsqlCommand("TRUNCATE player_gig_progression;", conn))
|
||||
{
|
||||
await truncateGig.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Gigs;
|
||||
|
||||
public sealed class GigProgressionSnapshotApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetGigProgression_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/players/missing-player/gig-progression");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGigProgression_ShouldReturnSchemaV1_WithDefaultXpAndLevel_ForPrototypeMainGig()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/players/dev-local-1/gig-progression");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<GigProgressionSnapshotResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(GigProgressionSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.Equal("dev-local-1", body.PlayerId);
|
||||
Assert.Equal(GigProgressionConstants.PrototypeMainGigId, body.MainGigId);
|
||||
Assert.NotNull(body.Gigs);
|
||||
var breach = Assert.Single(body.Gigs!);
|
||||
Assert.Equal(GigProgressionConstants.PrototypeMainGigId, breach.Id);
|
||||
Assert.Equal(0, breach.Xp);
|
||||
Assert.Equal(GigProgressionConstants.PrototypeFixedLevel, breach.Level);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ using NeonSprawl.Server.Game.Crafting;
|
|||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
|
@ -45,12 +46,16 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
using NeonSprawl.Server.Game.Npc;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||
|
||||
internal static class NpcBehaviorCatalogTestPaths
|
||||
{
|
||||
internal static string DiscoverRepoNpcBehaviorsDirectory() =>
|
||||
NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
|
||||
internal static string DiscoverRepoNpcBehaviorDefSchemaPath() =>
|
||||
NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorDefSchemaPath(
|
||||
DiscoverRepoNpcBehaviorsDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||
|
||||
public class NpcBehaviorDefinitionCatalogLoaderTests
|
||||
{
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"npcBehaviors": [
|
||||
{
|
||||
"id": "prototype_melee_pressure",
|
||||
"displayName": "Melee Pressure",
|
||||
"archetypeKind": "melee_pressure",
|
||||
"maxHp": 100,
|
||||
"aggroRadius": 8.0,
|
||||
"leashRadius": 16.0,
|
||||
"telegraphWindupSeconds": 1.5,
|
||||
"attackDamage": 15,
|
||||
"attackCooldownSeconds": 3.0
|
||||
},
|
||||
{
|
||||
"id": "prototype_ranged_control",
|
||||
"displayName": "Ranged Control",
|
||||
"archetypeKind": "ranged_control",
|
||||
"maxHp": 80,
|
||||
"aggroRadius": 10.0,
|
||||
"leashRadius": 20.0,
|
||||
"telegraphWindupSeconds": 2.0,
|
||||
"attackDamage": 12,
|
||||
"attackCooldownSeconds": 4.0
|
||||
},
|
||||
{
|
||||
"id": "prototype_elite_mini_boss",
|
||||
"displayName": "Elite Mini-Boss",
|
||||
"archetypeKind": "elite_mini_boss",
|
||||
"maxHp": 200,
|
||||
"aggroRadius": 8.0,
|
||||
"leashRadius": 18.0,
|
||||
"telegraphWindupSeconds": 2.5,
|
||||
"attackDamage": 25,
|
||||
"attackCooldownSeconds": 5.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static (string Root, string NpcBehaviorsDir, string SchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-npcbehaviorcat-");
|
||||
var npcBehaviorsDir = Path.Combine(root.FullName, "content", "npc-behaviors");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(npcBehaviorsDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "npc-behavior-def.schema.json");
|
||||
File.Copy(NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath(), schemaPath, overwrite: true);
|
||||
return (root.FullName, npcBehaviorsDir, schemaPath);
|
||||
}
|
||||
|
||||
private static void WriteCatalog(string npcBehaviorsDir, string catalogJson) =>
|
||||
File.WriteAllText(Path.Combine(npcBehaviorsDir, "prototype_npc_behaviors.json"), catalogJson, Encoding.UTF8);
|
||||
|
||||
private static JsonObject GetBehaviorRow(JsonObject catalogRoot, string behaviorId)
|
||||
{
|
||||
var npcBehaviors = catalogRoot["npcBehaviors"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected npcBehaviors array");
|
||||
foreach (var node in npcBehaviors)
|
||||
{
|
||||
if (node is JsonObject row && row["id"]?.GetValue<string>() == behaviorId)
|
||||
return row;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"npc behavior id not found: {behaviorId}");
|
||||
}
|
||||
|
||||
private static NpcBehaviorDefinitionCatalog LoadCatalog(string npcBehaviorsDir, string schemaPath) =>
|
||||
NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson);
|
||||
// Act
|
||||
var catalog = LoadCatalog(npcBehaviorsDir, schemaPath);
|
||||
// Assert
|
||||
Assert.Equal(3, catalog.DistinctBehaviorCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetBehavior("prototype_melee_pressure", out var melee));
|
||||
Assert.NotNull(melee);
|
||||
Assert.Equal("melee_pressure", melee!.ArchetypeKind);
|
||||
Assert.Equal(100, melee.MaxHp);
|
||||
Assert.Equal(8.0, melee.AggroRadius);
|
||||
Assert.Equal(16.0, melee.LeashRadius);
|
||||
Assert.Equal(15, melee.AttackDamage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNpcBehaviorsIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"),
|
||||
"""{"schemaVersion": 1, "npcBehaviors": "nope"}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'npcBehaviors' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"),
|
||||
"""{"schemaVersion": 2, "npcBehaviors": []}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateBehaviorIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson);
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "extra_npc_behaviors.json"),
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"npcBehaviors": [
|
||||
{
|
||||
"id": "prototype_melee_pressure",
|
||||
"displayName": "Duplicate Melee",
|
||||
"archetypeKind": "melee_pressure",
|
||||
"maxHp": 1,
|
||||
"aggroRadius": 1.0,
|
||||
"leashRadius": 2.0,
|
||||
"telegraphWindupSeconds": 1.0,
|
||||
"attackDamage": 1,
|
||||
"attackCooldownSeconds": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate npc behavior id 'prototype_melee_pressure'", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M2GateFailsWithMissingId()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
var npcBehaviors = root["npcBehaviors"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected npcBehaviors array");
|
||||
npcBehaviors.RemoveAt(npcBehaviors.Count - 1);
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenE5M2GateFailsWithExtraId()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_elite_mini_boss")["id"] = "prototype_extra";
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenLeashRadiusIsNotGreaterThanAggroRadius()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["leashRadius"] = 8.0;
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("leashRadius 8 must be > aggroRadius 8", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenAggroRadiusIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["aggroRadius"] = 0;
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("aggroRadius", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenMaxHpIsZero()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["maxHp"] = 0;
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("maxHp", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDisplayNameIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetBehaviorRow(root, "prototype_melee_pressure")["displayName"] = "";
|
||||
WriteCatalog(npcBehaviorsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNoNpcBehaviorCatalogFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("no *_npc_behaviors.json files", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenJsonIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"),
|
||||
"{not json",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNpcBehaviorsDirectoryMissing()
|
||||
{
|
||||
// Arrange
|
||||
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-npc-behaviors-" + Guid.NewGuid().ToString("n"));
|
||||
var schemaPath = NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath();
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
NpcBehaviorDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
||||
{
|
||||
// Arrange
|
||||
var (_, npcBehaviorsDir, _) = CreateTempContentLayout();
|
||||
var missingSchema = Path.Combine(npcBehaviorsDir, "missing-npc-behavior-def.schema.json");
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, missingSchema, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.GetAsync("/health");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var catalog = factory.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
|
||||
Assert.Equal(3, catalog.DistinctBehaviorCount);
|
||||
Assert.True(catalog.TryGetBehavior("prototype_elite_mini_boss", out var elite));
|
||||
Assert.Equal(200, elite!.MaxHp);
|
||||
Assert.Equal(25, elite.AttackDamage);
|
||||
Assert.Equal(5.0, elite.AttackCooldownSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-npc-behaviors-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(badDir);
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
b.UseSetting("Content:NpcBehaviorsDirectory", badDir));
|
||||
factory.CreateClient();
|
||||
});
|
||||
// Assert
|
||||
Assert.NotNull(ex);
|
||||
Assert.Contains("NPC behavior catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(badDir))
|
||||
Directory.Delete(badDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
using System.IO;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||
|
||||
public class NpcBehaviorDefinitionRegistryTests
|
||||
{
|
||||
private static NpcBehaviorDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, NpcBehaviorDefRow> byId)
|
||||
{
|
||||
var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1);
|
||||
return new NpcBehaviorDefinitionRegistry(catalog);
|
||||
}
|
||||
|
||||
private static NpcBehaviorDefRow MeleeRow() =>
|
||||
new(
|
||||
PrototypeNpcBehaviorRegistry.PrototypeMeleePressure,
|
||||
"Melee Pressure",
|
||||
"melee_pressure",
|
||||
100,
|
||||
8.0,
|
||||
16.0,
|
||||
1.5,
|
||||
15,
|
||||
3.0);
|
||||
|
||||
private static NpcBehaviorDefRow RangedRow() =>
|
||||
new(
|
||||
PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
|
||||
"Ranged Control",
|
||||
"ranged_control",
|
||||
80,
|
||||
10.0,
|
||||
20.0,
|
||||
2.0,
|
||||
12,
|
||||
4.0);
|
||||
|
||||
private static NpcBehaviorDefRow EliteRow() =>
|
||||
new(
|
||||
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
|
||||
"Elite Mini-Boss",
|
||||
"elite_mini_boss",
|
||||
200,
|
||||
8.0,
|
||||
18.0,
|
||||
2.5,
|
||||
25,
|
||||
5.0);
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, out var def);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(def);
|
||||
Assert.Equal(100, def.MaxHp);
|
||||
Assert.Equal(8.0, def.AggroRadius);
|
||||
Assert.Equal(16.0, def.LeashRadius);
|
||||
Assert.Equal(15, def.AttackDamage);
|
||||
Assert.Equal(1.5, def.TelegraphWindupSeconds);
|
||||
Assert.Equal("melee_pressure", def.ArchetypeKind);
|
||||
Assert.Equal("Melee Pressure", def.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenEliteExists()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = EliteRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, out var def);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(def);
|
||||
Assert.Equal(200, def.MaxHp);
|
||||
Assert.Equal(25, def.AttackDamage);
|
||||
Assert.Equal(2.5, def.TelegraphWindupSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnFalse_WhenBehaviorIdIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition(null, out var def);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition("not_a_real_behavior", out var def);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldReturnFalse_WhenIdHasMixedCase()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryGetDefinition("Prototype_Melee_Pressure", out var def);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(def);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown(" Prototype_Melee_Pressure ", out var normalized);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown(null, out var normalized);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal(string.Empty, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown(" ", out var normalized);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal(string.Empty, normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal("prototype_unknown", normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleBehaviors()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = RangedRow(),
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = EliteRow(),
|
||||
[PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = MeleeRow(),
|
||||
};
|
||||
var registry = CreateRegistryFromRows(rows);
|
||||
// Act
|
||||
var list = registry.GetDefinitionsInIdOrder();
|
||||
// Assert
|
||||
Assert.Equal(3, list.Count);
|
||||
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, list[0].Id);
|
||||
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, list[1].Id);
|
||||
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, list[2].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture()
|
||||
{
|
||||
// Arrange
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-npc-registry-loader-");
|
||||
try
|
||||
{
|
||||
var npcBehaviorsDir = Path.Combine(root.FullName, "content", "npc-behaviors");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(npcBehaviorsDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "npc-behavior-def.schema.json");
|
||||
File.Copy(NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath(), schemaPath, overwrite: true);
|
||||
File.Copy(
|
||||
Path.Combine(NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorsDirectory(), "prototype_npc_behaviors.json"),
|
||||
Path.Combine(npcBehaviorsDir, "prototype_npc_behaviors.json"),
|
||||
overwrite: true);
|
||||
var loaded = NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, NullLogger.Instance);
|
||||
var registry = new NpcBehaviorDefinitionRegistry(loaded);
|
||||
// Act
|
||||
var meleeOk = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, out var melee);
|
||||
var rangedOk = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, out var ranged);
|
||||
var eliteOk = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, out var elite);
|
||||
var list = registry.GetDefinitionsInIdOrder();
|
||||
// Assert
|
||||
Assert.True(meleeOk);
|
||||
Assert.NotNull(melee);
|
||||
Assert.Equal(100, melee!.MaxHp);
|
||||
Assert.Equal(8.0, melee.AggroRadius);
|
||||
Assert.Equal(16.0, melee.LeashRadius);
|
||||
Assert.Equal(15, melee.AttackDamage);
|
||||
Assert.Equal(1.5, melee.TelegraphWindupSeconds);
|
||||
Assert.True(rangedOk);
|
||||
Assert.NotNull(ranged);
|
||||
Assert.Equal(80, ranged!.MaxHp);
|
||||
Assert.Equal(10.0, ranged.AggroRadius);
|
||||
Assert.Equal(20.0, ranged.LeashRadius);
|
||||
Assert.Equal(12, ranged.AttackDamage);
|
||||
Assert.Equal(2.0, ranged.TelegraphWindupSeconds);
|
||||
Assert.True(eliteOk);
|
||||
Assert.NotNull(elite);
|
||||
Assert.Equal(200, elite!.MaxHp);
|
||||
Assert.Equal(8.0, elite.AggroRadius);
|
||||
Assert.Equal(18.0, elite.LeashRadius);
|
||||
Assert.Equal(25, elite.AttackDamage);
|
||||
Assert.Equal(2.5, elite.TelegraphWindupSeconds);
|
||||
Assert.Equal(3, list.Count);
|
||||
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, list[0].Id);
|
||||
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, list[^1].Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(root.FullName, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort: transient lock or race on some hosts; temp dir is unique per run.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
// Act
|
||||
var registry = factory.Services.GetRequiredService<INpcBehaviorDefinitionRegistry>();
|
||||
var meleeFound = registry.TryGetDefinition(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, out var melee);
|
||||
var unknown = registry.TryGetDefinition("not_a_real_behavior", out var missing);
|
||||
var normalized = registry.TryNormalizeKnown(" Prototype_Ranged_Control ", out var rangedId);
|
||||
var list = registry.GetDefinitionsInIdOrder();
|
||||
// Assert
|
||||
Assert.True(meleeFound);
|
||||
Assert.NotNull(melee);
|
||||
Assert.Equal(100, melee!.MaxHp);
|
||||
Assert.Equal(15, melee.AttackDamage);
|
||||
Assert.False(unknown);
|
||||
Assert.Null(missing);
|
||||
Assert.True(normalized);
|
||||
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, rangedId);
|
||||
Assert.Equal(3, list.Count);
|
||||
Assert.Contains(
|
||||
list,
|
||||
b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss && b.MaxHp == 200 && b.AttackDamage == 25);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||
|
||||
public class NpcBehaviorDefinitionsWorldApiTests
|
||||
{
|
||||
/// <summary>Frozen prototype three in registry id order (ordinal). Keep in sync with Bruno.</summary>
|
||||
public static readonly string[] FrozenThreeInIdOrder =
|
||||
[
|
||||
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
|
||||
PrototypeNpcBehaviorRegistry.PrototypeMeleePressure,
|
||||
PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task GetNpcBehaviorDefinitions_ShouldReturnSchemaV1_WithFrozenThreeInIdOrder()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/world/npc-behavior-definitions");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<NpcBehaviorDefinitionsListResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(NpcBehaviorDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.NotNull(body.NpcBehaviors);
|
||||
Assert.Equal(3, body.NpcBehaviors.Count);
|
||||
var ids = body.NpcBehaviors.Select(static b => b.Id).ToList();
|
||||
Assert.Equal(FrozenThreeInIdOrder, ids);
|
||||
|
||||
var melee = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeMeleePressure);
|
||||
Assert.Equal("Melee Pressure", melee.DisplayName);
|
||||
Assert.Equal("melee_pressure", melee.ArchetypeKind);
|
||||
Assert.Equal(100, melee.MaxHp);
|
||||
Assert.Equal(8.0, melee.AggroRadius);
|
||||
Assert.Equal(16.0, melee.LeashRadius);
|
||||
Assert.Equal(1.5, melee.TelegraphWindupSeconds);
|
||||
Assert.Equal(15, melee.AttackDamage);
|
||||
Assert.Equal(3.0, melee.AttackCooldownSeconds);
|
||||
|
||||
var elite = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss);
|
||||
Assert.Equal("Elite Mini-Boss", elite.DisplayName);
|
||||
Assert.Equal("elite_mini_boss", elite.ArchetypeKind);
|
||||
Assert.Equal(200, elite.MaxHp);
|
||||
Assert.Equal(8.0, elite.AggroRadius);
|
||||
Assert.Equal(18.0, elite.LeashRadius);
|
||||
Assert.Equal(2.5, elite.TelegraphWindupSeconds);
|
||||
Assert.Equal(25, elite.AttackDamage);
|
||||
Assert.Equal(5.0, elite.AttackCooldownSeconds);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Crafting;
|
|||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
|
@ -43,12 +44,16 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput;
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
|
@ -37,10 +38,14 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
|
|||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput;
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
|
@ -37,10 +38,14 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
|
|||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ using NeonSprawl.Server.Game.AbilityInput;
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
||||
|
|
@ -46,12 +48,16 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
|
||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
@ -62,6 +68,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerGigProgressionStore) ||
|
||||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
|
|
@ -82,6 +89,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using NeonSprawl.Server.Diagnostics;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
|
@ -15,6 +16,7 @@ namespace NeonSprawl.Server.Game.AbilityInput;
|
|||
/// 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 server cooldown window.
|
||||
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
|
||||
/// NEO-44: on accept when <see cref="CombatResult.TargetDefeated"/> is true, best-effort gig XP grant via <see cref="CombatDefeatGigXpGrant"/> (not E2.M2 skill XP).
|
||||
/// </remarks>
|
||||
public static class AbilityCastApi
|
||||
{
|
||||
|
|
@ -61,6 +63,8 @@ public static class AbilityCastApi
|
|||
IPlayerAbilityCooldownStore cooldowns,
|
||||
IAbilityDefinitionRegistry abilities,
|
||||
ICombatEntityHealthStore healthStore,
|
||||
IPlayerGigProgressionStore gigStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider clock) =>
|
||||
{
|
||||
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
|
||||
|
|
@ -224,6 +228,14 @@ public static class AbilityCastApi
|
|||
now,
|
||||
TimeSpan.FromSeconds(definition.CooldownSeconds));
|
||||
|
||||
if (combatResult.TargetDefeated)
|
||||
{
|
||||
CombatDefeatGigXpGrant.GrantOnCombatDefeat(
|
||||
id.Trim(),
|
||||
gigStore,
|
||||
loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant)));
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@ 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).
|
||||
/// NEO-84 telemetry hook sites: <see cref="TryResolve"/> (<c>ability_used</c>, <c>enemy_defeat</c>).
|
||||
/// </summary>
|
||||
public static class CombatOperations
|
||||
{
|
||||
// --- Reserved telemetry hook sites (NEO-84): no emit until future modules ---
|
||||
// encounter_start — future E5.M3 encounter spawn/engage path (not wired in Slice 1).
|
||||
// player_death — future player HP / death resolution (player HP out of scope in Slice 1).
|
||||
/// <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.
|
||||
|
|
@ -34,6 +38,10 @@ public static class CombatOperations
|
|||
|
||||
if (definition.BaseDamage == 0)
|
||||
{
|
||||
// --- Telemetry hook site (NEO-84): future E9.M1 catalog event `ability_used` ---
|
||||
// TODO(E9.M1): catalog emit — on every successful resolve (including zero-damage abilities).
|
||||
// Planned payload fields: abilityId, targetId, damageDealt (0), targetRemainingHp, targetDefeated.
|
||||
// playerId is not in engine scope; correlate route id at AbilityCastApi accept (NEO-30 funnel).
|
||||
return new CombatResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
|
|
@ -47,6 +55,17 @@ public static class CombatOperations
|
|||
return Deny(CombatReasonCodes.UnknownTarget);
|
||||
}
|
||||
|
||||
if (after.Defeated)
|
||||
{
|
||||
// --- Telemetry hook site (NEO-84): future E9.M1 catalog event `enemy_defeat` ---
|
||||
// TODO(E9.M1): catalog emit — once per target defeat transition (lethal hit only; re-hit denied earlier).
|
||||
// Planned payload fields: targetId, abilityId (killing blow), damageDealt, final HP 0; correlate playerId at cast layer.
|
||||
}
|
||||
|
||||
// --- Telemetry hook site (NEO-84): future E9.M1 catalog event `ability_used` ---
|
||||
// TODO(E9.M1): catalog emit — on every successful resolve after damage application.
|
||||
// Planned payload fields: abilityId, targetId, damageDealt, targetRemainingHp, targetDefeated.
|
||||
// playerId is not in engine scope; correlate route id at AbilityCastApi accept (NEO-30 funnel).
|
||||
return new CombatResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ namespace NeonSprawl.Server.Game.Combat;
|
|||
|
||||
/// <summary>
|
||||
/// Server-internal combat resolution envelope (NEO-81); promoted to wire JSON via cast response extension (NEO-82).
|
||||
/// NEO-84 telemetry hook sites live in <see cref="CombatOperations.TryResolve"/>.
|
||||
/// </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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>
|
||||
/// NEO-44: combat encounter defeat grants gig XP on the prototype main gig only — not E2.M2 skill XP.
|
||||
/// Outcome is ignored on the cast accept path (best-effort apply); inspect store/GET when verification matters.
|
||||
/// </summary>
|
||||
public static class CombatDefeatGigXpGrant
|
||||
{
|
||||
/// <summary>Applies one combat-defeat grant to the prototype main gig when <paramref name="playerId"/> is writable.</summary>
|
||||
public static void GrantOnCombatDefeat(
|
||||
string playerId,
|
||||
IPlayerGigProgressionStore gigStore,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
if (!gigStore.TryApplyXpDelta(
|
||||
playerId,
|
||||
GigProgressionConstants.PrototypeMainGigId,
|
||||
GigProgressionConstants.CombatDefeatXpAmount,
|
||||
out _,
|
||||
out _))
|
||||
{
|
||||
logger?.LogDebug(
|
||||
"Combat defeat gig XP grant skipped for player {PlayerId} (store write failed)",
|
||||
playerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>NEO-44: prototype combat defeat → main gig XP (outside E2.M2 skill progression).</summary>
|
||||
public static class GigProgressionConstants
|
||||
{
|
||||
/// <summary>All players implicitly main this gig until loadout/hub swap lands.</summary>
|
||||
public const string PrototypeMainGigId = "breach";
|
||||
|
||||
public const int CombatDefeatXpAmount = 25;
|
||||
|
||||
/// <summary>Fixed until gig level-curve content exists (NEO-37 pre-curve pattern).</summary>
|
||||
public const int PrototypeFixedLevel = 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Registers gig XP persistence: PostgreSQL when configured, otherwise in-memory fallback (NEO-44).</summary>
|
||||
public static class GigProgressionServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddGigProgressionStore(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||
if (!string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
services.AddSingleton<IPlayerGigProgressionStore, PostgresPlayerGigProgressionStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPlayerGigProgressionStore, InMemoryPlayerGigProgressionStore>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Maps <c>GET /game/players/{{id}}/gig-progression</c> — read snapshot (NEO-44).</summary>
|
||||
public static class GigProgressionSnapshotApi
|
||||
{
|
||||
public static WebApplication MapGigProgressionSnapshotApi(this WebApplication app)
|
||||
{
|
||||
app.MapGet(
|
||||
"/game/players/{id}/gig-progression",
|
||||
(string id, IPositionStateStore positions, IPlayerGigProgressionStore gigStore) =>
|
||||
{
|
||||
var trimmedId = id.Trim();
|
||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Json(BuildSnapshot(trimmedId, gigStore));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static GigProgressionSnapshotResponse BuildSnapshot(
|
||||
string playerId,
|
||||
IPlayerGigProgressionStore store)
|
||||
{
|
||||
var xpByGig = store.GetXpTotals(playerId);
|
||||
xpByGig.TryGetValue(GigProgressionConstants.PrototypeMainGigId, out var xp);
|
||||
|
||||
return new GigProgressionSnapshotResponse
|
||||
{
|
||||
PlayerId = playerId,
|
||||
MainGigId = GigProgressionConstants.PrototypeMainGigId,
|
||||
Gigs =
|
||||
[
|
||||
new GigProgressionRowJson
|
||||
{
|
||||
Id = GigProgressionConstants.PrototypeMainGigId,
|
||||
Xp = xp,
|
||||
Level = GigProgressionConstants.PrototypeFixedLevel,
|
||||
},
|
||||
],
|
||||
SchemaVersion = GigProgressionSnapshotResponse.CurrentSchemaVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/players/{{id}}/gig-progression</c> (NEO-44).</summary>
|
||||
public sealed class GigProgressionSnapshotResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("playerId")]
|
||||
public required string PlayerId { get; init; }
|
||||
|
||||
/// <summary>Prototype main gig until loadout/hub swap lands.</summary>
|
||||
[JsonPropertyName("mainGigId")]
|
||||
public required string MainGigId { get; init; }
|
||||
|
||||
[JsonPropertyName("gigs")]
|
||||
public required IReadOnlyList<GigProgressionRowJson> Gigs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>XP and level for one gig row in the prototype snapshot.</summary>
|
||||
public sealed class GigProgressionRowJson
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("xp")]
|
||||
public int Xp { get; init; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public int Level { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Persisted totals of gig XP per player (NEO-44); level is derived when gig curves exist.</summary>
|
||||
public interface IPlayerGigProgressionStore
|
||||
{
|
||||
/// <summary>Keyed by gig id; omitted gigs imply XP 0.</summary>
|
||||
IReadOnlyDictionary<string, int> GetXpTotals(string playerId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="amount"/> to <paramref name="gigId"/> for <paramref name="playerId"/>.
|
||||
/// Returns <c>false</c> when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from <c>player_position</c>).
|
||||
/// </summary>
|
||||
bool TryApplyXpDelta(string playerId, string gigId, int amount, out int previousXp, out int newXp);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Thread-safe in-memory gig progression; seeds the configured dev player with an empty XP map (NEO-44).</summary>
|
||||
public sealed class InMemoryPlayerGigProgressionStore(IOptions<GamePositionOptions> options) : IPlayerGigProgressionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, int>> byPlayer = CreateInitialMap(options.Value);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static ConcurrentDictionary<string, ConcurrentDictionary<string, int>> CreateInitialMap(GamePositionOptions o)
|
||||
{
|
||||
var id = NormalizePlayerId(o.DevPlayerId);
|
||||
var map = new ConcurrentDictionary<string, ConcurrentDictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
|
||||
map[id] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||
{
|
||||
return ReadOnlyDic.Empty;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
return new Dictionary<string, int>(inner, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryApplyXpDelta(string playerId, string gigId, int amount, out int previousXp, out int newXp)
|
||||
{
|
||||
previousXp = 0;
|
||||
newXp = 0;
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || gigId.Trim().Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var gid = gigId.Trim();
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
inner.TryGetValue(gid, out var prev);
|
||||
previousXp = prev;
|
||||
newXp = prev + amount;
|
||||
if (newXp < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
inner[gid] = newXp;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static class ReadOnlyDic
|
||||
{
|
||||
internal static readonly IReadOnlyDictionary<string, int> Empty = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>Applies NEO-44 gig progression table DDL once per process.</summary>
|
||||
public static class PostgresGigProgressionBootstrap
|
||||
{
|
||||
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V007__player_gig_progression.sql");
|
||||
|
||||
private static readonly object SchemaGate = new();
|
||||
|
||||
private static int _schemaReady;
|
||||
|
||||
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SchemaGate)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _schemaReady) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||
if (!File.Exists(ddlPath))
|
||||
{
|
||||
throw new FileNotFoundException($"NEO-44 DDL not found at '{ddlPath}'.", ddlPath);
|
||||
}
|
||||
|
||||
var ddl = File.ReadAllText(ddlPath);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||
cmd.ExecuteNonQuery();
|
||||
System.Threading.Volatile.Write(ref _schemaReady, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
namespace NeonSprawl.Server.Game.Gigs;
|
||||
|
||||
/// <summary>PostgreSQL-backed gig XP totals keyed by normalized player id (NEO-44).</summary>
|
||||
public sealed class PostgresPlayerGigProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerGigProgressionStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
return new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
PostgresGigProgressionBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT gig_id, xp
|
||||
FROM player_gig_progression
|
||||
WHERE player_id = @pid;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", norm);
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
map[reader.GetString(0)] = reader.GetInt32(1);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryApplyXpDelta(string playerId, string gigId, int amount, out int previousXp, out int newXp)
|
||||
{
|
||||
previousXp = 0;
|
||||
newXp = 0;
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
var gid = gigId.Trim();
|
||||
if (norm.Length == 0 || gid.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresGigProgressionBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, norm, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
using (var sel = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
SELECT xp
|
||||
FROM player_gig_progression
|
||||
WHERE player_id = @pid AND gig_id = @gid
|
||||
LIMIT 1
|
||||
FOR UPDATE;
|
||||
""",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
sel.Parameters.AddWithValue("pid", norm);
|
||||
sel.Parameters.AddWithValue("gid", gid);
|
||||
var scalar = sel.ExecuteScalar();
|
||||
previousXp = scalar is null || Equals(scalar, DBNull.Value)
|
||||
? 0
|
||||
: scalar is int xi
|
||||
? xi
|
||||
: Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
newXp = previousXp + amount;
|
||||
if (newXp < 0)
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
using var upsert = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_gig_progression (player_id, gig_id, xp, updated_at)
|
||||
VALUES (@pid, @gid, @xp, now())
|
||||
ON CONFLICT (player_id, gig_id)
|
||||
DO UPDATE SET xp = EXCLUDED.xp, updated_at = now();
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
upsert.Parameters.AddWithValue("pid", norm);
|
||||
upsert.Parameters.AddWithValue("gid", gid);
|
||||
upsert.Parameters.AddWithValue("xp", newXp);
|
||||
upsert.ExecuteNonQuery();
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||
conn,
|
||||
tx);
|
||||
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(t))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return t.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue