Compare commits

...

22 Commits

Author SHA1 Message Date
VinPropane 7c0e2e8cd6
Merge pull request #139 from ViPro-Technologies/NEO-100-e5m3-encounter-reward-catalog-schemas-ci
NEO-100: E5M3-01 encounter + reward-table catalogs and CI
2026-05-30 19:45:46 -04:00
VinPropane ebf385d658 NEO-100: Address code review — register sync and grant dedupe
Update E5.M3 dependency register note for landed catalog, reject duplicate
fixedGrants itemId in CI, add uniqueItems on requiredNpcInstanceIds, and
record review follow-ups as Done.
2026-05-30 19:38:33 -04:00
VinPropane 01b7e23c5e NEO-100: Add code review for encounter + reward catalog CI 2026-05-30 19:36:14 -04:00
VinPropane ee5e1a0caf NEO-100: Add encounter and reward-table catalogs with CI validation
Ship frozen prototype_combat_pocket spine (schemas, JSON catalogs, validate_content
gates) and update E5.M3, CT.M1, and alignment docs for E5M3-01 completion.
2026-05-30 19:35:02 -04:00
VinPropane 4f16c848db NEO-100: Kickoff plan for encounter + reward catalog schemas and CI
Document frozen prototype_combat_pocket spine, schema shapes, validate_content
gates, and file lists before E5M3-01 implementation.
2026-05-30 19:33:27 -04:00
VinPropane cb86645524
Merge pull request #138 from ViPro-Technologies/chore/epic-5-docs
chore: Epic 5 docs — E5.M3 decomposition and anchor stability
2026-05-30 19:32:15 -04:00
VinPropane d7c3ede5a7 chore: Epic 5 stable slice anchors and doc cluster alignment
Add explicit HTML slice anchors to epic_05, restore deep links across E5
backlogs and plans, fix E5M2 NEO-98 resolve vocabulary and E5.M3 Linear row,
and record review follow-up as Done.
2026-05-30 19:25:56 -04:00
VinPropane db9ddb9fe0 chore: Decompose E5.M3 prototype backlog (NEO-100–NEO-111)
Epic 5 Slice 3 encounter + reward routing: backlog doc, module/epic/register
updates, and Linear issues for one combat-pocket clear with idempotent loot.
2026-05-30 18:38:18 -04:00
VinPropane 2f1b7f10be
Merge pull request #137 from ViPro-Technologies/NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone
NEO-98: E5M2-12 playable NPC telegraph combat capstone
2026-05-30 18:34:32 -04:00
VinPropane e6b8386102 NEO-98: Remove unused ability gate alias in validate_content. 2026-05-30 18:29:30 -04:00
VinPropane 04126693e1 NEO-98: Update Bruno for seven abilities and attackAbilityId. 2026-05-30 18:06:42 -04:00
VinPropane b77b7f8fbb NEO-98: Address follow-up review doc findings.
Update E5.M2 module freeze, plan scope reconciliation, alignment
register, client README melee row, and strike-through follow-up review.
2026-05-30 18:02:55 -04:00
VinPropane bc1357cf94 NEO-98: add follow-up code review for capstone integration fixes 2026-05-30 18:01:52 -04:00
VinPropane 44fb4adbe5 NEO-98: Set melee strike to 2 m and elite slam to 4 m. 2026-05-30 17:55:10 -04:00
VinPropane 99aa2b455f NEO-98: Document melee 1 m strike range in manual QA. 2026-05-30 17:54:06 -04:00
VinPropane b9e2d2f4c9 NEO-98: Shorten NPC melee strike maxRange to 1 m. 2026-05-30 17:53:14 -04:00
VinPropane 3169106f28 NEO-98: Resolve NPC telegraph hits via attack ability maxRange.
Telegraph damage now looks up behavior attackAbilityId for baseDamage and
maxRange instead of using aggroRadius. Adds three NPC attack abilities, maxRange
on AbilityDef, attackAbilityId on NpcBehaviorDef, and cross-ref validation.
2026-05-30 17:49:21 -04:00
VinPropane 332cd4445f NEO-98: stop defeated NPC aggro and runtime attacks
Clear threat holder and reset runtime to idle when combat-target HP
is defeated — on cast accept and as an AdvanceOne guard so telegraph
cycles cannot continue after the killing blow.
2026-05-30 17:38:27 -04:00
VinPropane b03128a3b0 NEO-98: address code review — Esc regression step and AC parity 2026-05-30 17:05:30 -04:00
VinPropane 817ed26304 NEO-98: add code review for E5M2-12 capstone docs 2026-05-30 17:04:18 -04:00
VinPropane ae8b01d5e3 NEO-98: add capstone manual QA and Slice 2 docs 2026-05-30 17:02:50 -04:00
VinPropane ceef54cd23 NEO-98: add implementation plan for NPC telegraph combat capstone 2026-05-30 16:55:30 -04:00
62 changed files with 2485 additions and 169 deletions

View File

@ -17,7 +17,7 @@ tests {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.abilities).to.be.an("array");
expect(body.abilities.length).to.equal(4);
expect(body.abilities.length).to.equal(7);
});
test("abilities are ascending by id (ordinal)", function () {
@ -27,13 +27,16 @@ tests {
expect(ids).to.eql(sorted);
});
test("frozen prototype four matches registry id order", function () {
test("frozen prototype seven matches registry id order", function () {
const body = res.getBody();
const ids = body.abilities.map((x) => x.id);
expect(ids).to.eql([
"prototype_burst",
"prototype_dash",
"prototype_guard",
"prototype_npc_elite_slam",
"prototype_npc_melee_strike",
"prototype_npc_ranged_shot",
"prototype_pulse",
]);
});
@ -44,6 +47,7 @@ tests {
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Prototype Pulse");
expect(row.baseDamage).to.equal(25);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(3);
expect(row.abilityKind).to.equal("attack");
});
@ -54,6 +58,7 @@ tests {
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Prototype Burst");
expect(row.baseDamage).to.equal(40);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(5);
expect(row.abilityKind).to.equal("attack");
});
@ -64,6 +69,7 @@ tests {
expect(row).to.be.an("object");
expect(row.abilityKind).to.equal("utility");
expect(row.baseDamage).to.equal(0);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(6);
});
@ -73,6 +79,37 @@ tests {
expect(row).to.be.an("object");
expect(row.abilityKind).to.equal("movement");
expect(row.baseDamage).to.equal(0);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(4);
});
test("prototype_npc_melee_strike row matches catalog", function () {
const body = res.getBody();
const row = body.abilities.find((x) => x.id === "prototype_npc_melee_strike");
expect(row).to.be.an("object");
expect(row.baseDamage).to.equal(15);
expect(row.maxRange).to.equal(2);
expect(row.cooldownSeconds).to.equal(3);
expect(row.abilityKind).to.equal("attack");
});
test("prototype_npc_ranged_shot row matches catalog", function () {
const body = res.getBody();
const row = body.abilities.find((x) => x.id === "prototype_npc_ranged_shot");
expect(row).to.be.an("object");
expect(row.baseDamage).to.equal(12);
expect(row.maxRange).to.equal(10);
expect(row.cooldownSeconds).to.equal(4);
expect(row.abilityKind).to.equal("attack");
});
test("prototype_npc_elite_slam row matches catalog", function () {
const body = res.getBody();
const row = body.abilities.find((x) => x.id === "prototype_npc_elite_slam");
expect(row).to.be.an("object");
expect(row.baseDamage).to.equal(25);
expect(row.maxRange).to.equal(4);
expect(row.cooldownSeconds).to.equal(5);
expect(row.abilityKind).to.equal("attack");
});
}

View File

@ -49,6 +49,22 @@ tests {
expect(row.telegraphWindupSeconds).to.equal(1.5);
expect(row.attackDamage).to.equal(15);
expect(row.attackCooldownSeconds).to.equal(3);
expect(row.attackAbilityId).to.equal("prototype_npc_melee_strike");
});
test("prototype_ranged_control row matches catalog", function () {
const body = res.getBody();
const row = body.npcBehaviors.find((x) => x.id === "prototype_ranged_control");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Ranged Control");
expect(row.archetypeKind).to.equal("ranged_control");
expect(row.maxHp).to.equal(80);
expect(row.aggroRadius).to.equal(10);
expect(row.leashRadius).to.equal(20);
expect(row.telegraphWindupSeconds).to.equal(2);
expect(row.attackDamage).to.equal(12);
expect(row.attackCooldownSeconds).to.equal(4);
expect(row.attackAbilityId).to.equal("prototype_npc_ranged_shot");
});
test("prototype_elite_mini_boss row matches catalog", function () {
@ -63,5 +79,6 @@ tests {
expect(row.telegraphWindupSeconds).to.equal(2.5);
expect(row.attackDamage).to.equal(25);
expect(row.attackCooldownSeconds).to.equal(5);
expect(row.attackAbilityId).to.equal("prototype_npc_elite_slam");
});
}

View File

@ -163,6 +163,31 @@ Full checklist: [`docs/manual-qa/NEO-85.md`](../docs/manual-qa/NEO-85.md).
Full checklist: [`docs/manual-qa/NEO-97.md`](../docs/manual-qa/NEO-97.md).
## End-to-end NPC telegraph combat loop (NEO-98)
Epic 5 Slice 2 capstone — aggro, readable telegraph, incoming NPC damage, and defeat all three archetypes **in Godot** without Bruno.
**Flow:** fresh server restart → boot hotbar sync → **Melee** lock → cast → telegraph HUD → player HP drop → defeat → repeat **Ranged** + **Elite**.
| Step | Input / trigger | HUD / server outcome |
|------|-----------------|----------------------|
| Boot | Godot **F5** + fresh server | Hotbar slot 0 binds **`prototype_pulse`**; player combat HP **100/100** when poll starts |
| Melee lock | **Tab** on **`prototype_npc_melee`** | Marker brightens; **`CombatTargetHpLabel`** **`Target HP: prototype_npc_melee 100/100`** |
| Melee aggro | **1** (damaging cast) | **`NpcStateLabel`** → **`aggro`**; combat poll ~1 Hz |
| Melee telegraph | Wait **≥ 3 s** | **`telegraph_windup`** + **`TelegraphLabel`** countdown |
| Melee hit | Wait **≥ 4.5 s** from first cast; stay **within ~2 m** of melee marker | **`PlayerCombatHpLabel`** → **`85/100`** (15 damage); back off during windup → whiff |
| Melee defeat | **1** ×4 total (cooldown between) | **`CombatTargetHpLabel`** includes **`0/100 (defeated)`** |
| Ranged | Lock + cast ×4; wait **≥ 4 s** for telegraph | Defeat at **80** HP; telegraph visible before damage |
| Elite | Lock + cast ×8; wait **≥ 5 s** for telegraph | Defeat at **200** HP; telegraph visible before damage |
**Cross-links:** [NEO-92](../docs/plans/NEO-92-implementation-plan.md) aggro · [NEO-94](../docs/plans/NEO-94-implementation-plan.md) runtime snapshot · [NEO-95](../docs/plans/NEO-95-implementation-plan.md) player combat HP · [NEO-97](../docs/manual-qa/NEO-97.md) telegraph HUD components · [NEO-85](../docs/manual-qa/NEO-85.md) combat-target HP · [NEO-28](../docs/manual-qa/NEO-28.md) cast deny HUD.
**Scripts:** `npc_runtime_client.gd`, `player_combat_health_client.gd`, `npc_runtime_hud_state.gd`, `npc_combat_hud_helpers.gd`, `combat_targets_client.gd`, `ability_cast_client.gd` — wired from `main.gd`.
**Preconditions:** **Server restart** before capstone run resets NPC HP, aggro/runtime rows, and player combat HP.
Full capstone checklist: [`docs/manual-qa/NEO-98.md`](../docs/manual-qa/NEO-98.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.

View File

@ -11,6 +11,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
| [`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) |
| [`encounters/`](encounters/) | Encounter templates; each row matches [`schemas/encounter-def.schema.json`](schemas/encounter-def.schema.json) — **stable `id`**, completion criteria, **`rewardTableId`** for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) |
| [`reward-tables/`](reward-tables/) | Reward table catalogs; each row matches [`schemas/reward-table.schema.json`](schemas/reward-table.schema.json) — **stable `id`**, **`fixedGrants`** referencing frozen item ids for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.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.
@ -25,6 +27,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
**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 E5 Slice 3 — encounters + rewards (NEO-100):** CI expects **exactly one** encounter id **`prototype_combat_pocket`** and one reward table id **`prototype_combat_pocket_clear`** aligned to [E5.M3 encounter freeze](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01). **`requiredNpcInstanceIds`** must match the three E5.M2 NPC instance ids; **`fixedGrants`** reference frozen item ids only. **Do not rename** ids after ship—change **`displayName`** only. See [E5M3-prototype-backlog.md](../docs/plans/E5M3-prototype-backlog.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.

View File

@ -5,6 +5,7 @@
"id": "prototype_pulse",
"displayName": "Prototype Pulse",
"baseDamage": 25,
"maxRange": 6.0,
"cooldownSeconds": 3.0,
"abilityKind": "attack"
},
@ -12,6 +13,7 @@
"id": "prototype_guard",
"displayName": "Prototype Guard",
"baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 6.0,
"abilityKind": "utility"
},
@ -19,6 +21,7 @@
"id": "prototype_dash",
"displayName": "Prototype Dash",
"baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 4.0,
"abilityKind": "movement"
},
@ -26,6 +29,7 @@
"id": "prototype_burst",
"displayName": "Prototype Burst",
"baseDamage": 40,
"maxRange": 6.0,
"cooldownSeconds": 5.0,
"abilityKind": "attack"
}

View File

@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"abilities": [
{
"id": "prototype_npc_melee_strike",
"displayName": "NPC Melee Strike",
"baseDamage": 15,
"maxRange": 2.0,
"cooldownSeconds": 3.0,
"abilityKind": "attack"
},
{
"id": "prototype_npc_ranged_shot",
"displayName": "NPC Ranged Shot",
"baseDamage": 12,
"maxRange": 10.0,
"cooldownSeconds": 4.0,
"abilityKind": "attack"
},
{
"id": "prototype_npc_elite_slam",
"displayName": "NPC Elite Slam",
"baseDamage": 25,
"maxRange": 4.0,
"cooldownSeconds": 5.0,
"abilityKind": "attack"
}
]
}

View File

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"encounters": [
{
"id": "prototype_combat_pocket",
"displayName": "Prototype Combat Pocket",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite"
],
"rewardTableId": "prototype_combat_pocket_clear"
}
]
}

View File

@ -10,7 +10,8 @@
"leashRadius": 16.0,
"telegraphWindupSeconds": 1.5,
"attackDamage": 15,
"attackCooldownSeconds": 3.0
"attackCooldownSeconds": 3.0,
"attackAbilityId": "prototype_npc_melee_strike"
},
{
"id": "prototype_ranged_control",
@ -21,7 +22,8 @@
"leashRadius": 20.0,
"telegraphWindupSeconds": 2.0,
"attackDamage": 12,
"attackCooldownSeconds": 4.0
"attackCooldownSeconds": 4.0,
"attackAbilityId": "prototype_npc_ranged_shot"
},
{
"id": "prototype_elite_mini_boss",
@ -32,7 +34,8 @@
"leashRadius": 18.0,
"telegraphWindupSeconds": 2.5,
"attackDamage": 25,
"attackCooldownSeconds": 5.0
"attackCooldownSeconds": 5.0,
"attackAbilityId": "prototype_npc_elite_slam"
}
]
}

View File

@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"rewardTables": [
{
"id": "prototype_combat_pocket_clear",
"displayName": "Prototype Combat Pocket Clear",
"fixedGrants": [
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
{ "itemId": "contract_handoff_token", "quantity": 1 }
]
}
]
}

View File

@ -5,7 +5,7 @@
"description": "Single combat ability row for catalogs (e.g. content/abilities/*_abilities.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M1_CombatRulesEngine.md.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "baseDamage", "cooldownSeconds"],
"required": ["id", "displayName", "baseDamage", "maxRange", "cooldownSeconds"],
"properties": {
"id": {
"type": "string",
@ -22,6 +22,11 @@
"minimum": 0,
"description": "Deterministic damage dealt on successful combat resolve in prototype Slice 1 (no miss/crit RNG)."
},
"maxRange": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Horizontal maximum reach for this ability at combat resolve (XZ-only at runtime)."
},
"cooldownSeconds": {
"type": "number",
"exclusiveMinimum": 0,

View File

@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/encounter-def.json",
"title": "EncounterDef",
"description": "Single encounter template row for catalogs (e.g. content/encounters/*_encounters.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md.",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"displayName",
"completionCriteria",
"requiredNpcInstanceIds",
"rewardTableId"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable encounter key for progress tracking and completion events."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"completionCriteria": {
"type": "object",
"additionalProperties": false,
"required": ["kind"],
"properties": {
"kind": {
"type": "string",
"description": "Prototype Slice 3: defeat all listed NPC instances.",
"enum": ["defeat_all_targets"]
}
}
},
"requiredNpcInstanceIds": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"description": "NPC instance ids from server PrototypeNpcRegistry (CI cross-ref set gate)."
},
"rewardTableId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "RewardTable id granted once on encounter complete."
}
}
}

View File

@ -14,7 +14,8 @@
"leashRadius",
"telegraphWindupSeconds",
"attackDamage",
"attackCooldownSeconds"
"attackCooldownSeconds",
"attackAbilityId"
],
"properties": {
"id": {
@ -40,7 +41,7 @@
"aggroRadius": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Horizontal proximity radius for re-aggro after leash clear (XZ-only at runtime)."
"description": "Horizontal proximity radius for future re-aggro (XZ-only at runtime; must be less than leashRadius). Distinct from ability maxRange."
},
"leashRadius": {
"type": "number",
@ -61,6 +62,11 @@
"type": "number",
"exclusiveMinimum": 0,
"description": "Seconds after last attack before the NPC may enter telegraph again."
},
"attackAbilityId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable ability id used at telegraph resolve for maxRange and baseDamage (must exist in ability catalog)."
}
}
}

View File

@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/reward-grant-row.json",
"title": "RewardGrantRow",
"description": "Single fixed-grant row on a RewardTable (content/reward-tables/*_reward_tables.json).",
"type": "object",
"additionalProperties": false,
"required": ["itemId", "quantity"],
"properties": {
"itemId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Item def id from content/items (prototype Slice 1: frozen six-item catalog)."
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "Units granted on encounter complete."
}
}
}

View File

@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/reward-table.json",
"title": "RewardTable",
"description": "Single reward table row for catalogs (e.g. content/reward-tables/*_reward_tables.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "fixedGrants"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable reward table key for encounter binding and loot routing."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"fixedGrants": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "https://neon-sprawl.local/schemas/reward-grant-row.json"
}
}
}
}

View File

@ -46,6 +46,8 @@ Deliver tab-target combat resolution, NPC behaviors and telegraphs, encounter te
## Implementation Slices (Backlog-Ready)
<a id="epic-5-slice-1"></a>
### Slice 1 - Combat rules MVP
- Scope: E5.M1 with target lock, basic attacks, 4-6 abilities, cooldowns/resources per prototype scope.
@ -56,6 +58,8 @@ Deliver tab-target combat resolution, NPC behaviors and telegraphs, encounter te
**Linear backlog:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) — [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44).
<a id="epic-5-slice-2"></a>
### Slice 2 - NPC archetypes and telegraphs
- Scope: E5.M2 for three archetypes: melee pressure, ranged control, elite mini-boss.
@ -66,14 +70,20 @@ Deliver tab-target combat resolution, NPC behaviors and telegraphs, encounter te
**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.
<a id="epic-5-slice-3"></a>
### Slice 3 - Encounters and loot routing
- Scope: E5.M3 wired to E3.M3 and E7.M2 for rewards and quest progress.
- Dependencies: E5.M2, E3.M3, E7.M2
- Dependencies: E5.M2, E3.M3, E7.M2 (quest router stub + quest-token loot until E7.M2 lands)
- Acceptance criteria:
- Completing defined encounters grants expected items/quest credit once.
- Telemetry hooks: `encounter_complete`, reward attribution.
**Linear backlog:** [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) — [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). Verify in **Godot**, not Bruno-only.
<a id="epic-5-slice-4"></a>
### Slice 4 - Group scaling (pre-production)
- Scope: E5.M4 with party-sized modifiers behind E8.M1.
@ -89,6 +99,8 @@ Deliver tab-target combat resolution, NPC behaviors and telegraphs, encounter te
- Risk: Loot tables desync from item schema.
- Mitigation: Shared validation in content pipeline; lint reward references.
<a id="epic-5-definition-of-done"></a>
## Definition of Done
- Prototype combat, archetypes, and encounters meet documented minimums in master plan.

View File

@ -55,6 +55,10 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
**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`).
**Reward table catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/reward-tables/*_reward_tables.json` against [`content/schemas/reward-table.schema.json`](../../../content/schemas/reward-table.schema.json) (grant rows via [`reward-grant-row.schema.json`](../../../content/schemas/reward-grant-row.schema.json)), rejects **duplicate `id`** across files and **duplicate `itemId`** within a table's **`fixedGrants`**, cross-checks every **`fixedGrants[].itemId`** against item catalogs, and (E5 Slice 3) enforces the **frozen one-table** id set (`prototype_combat_pocket_clear`) with fixed grant quantities.
**Encounter catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/encounters/*_encounters.json` against [`content/schemas/encounter-def.schema.json`](../../../content/schemas/encounter-def.schema.json), rejects **duplicate `id`** across files, cross-checks **`rewardTableId`** against reward table catalogs, and (E5 Slice 3) enforces the **frozen one-encounter** id set (`prototype_combat_pocket`) with **`requiredNpcInstanceIds`** matching the three E5.M2 NPC instance ids.
**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

View File

@ -7,8 +7,8 @@
| **Module ID** | E5.M2 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **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-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks 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-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
| **Status** | Ready — 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-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed · **E5M2-11** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) client telegraph HUD landed · **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) playable capstone landed |
| **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-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) |
## Purpose
@ -67,11 +67,19 @@ Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M2-prototype-
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 |
| `NpcBehaviorDef.id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` | `attackAbilityId` |
|---------------------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------|-------------------|
| **`prototype_melee_pressure`** | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 | **`prototype_npc_melee_strike`** |
| **`prototype_ranged_control`** | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 | **`prototype_npc_ranged_shot`** |
| **`prototype_elite_mini_boss`** | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 | **`prototype_npc_elite_slam`** |
**NPC attack abilities (NEO-98):** [`content/abilities/prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) — telegraph resolve uses ability **`maxRange`** + **`baseDamage`**, not behavior **`aggroRadius`**. Behavior **`attackDamage`** remains on the HTTP projection and must match ability **`baseDamage`** (CI cross-ref). Strike **`maxRange`** (horizontal X/Z): melee **2 m**, ranged **10 m**, elite **4 m**.
| Ability `id` | `baseDamage` | `maxRange` |
|--------------|--------------|------------|
| **`prototype_npc_melee_strike`** | 15 | 2.0 |
| **`prototype_npc_ranged_shot`** | 12 | 10.0 |
| **`prototype_npc_elite_slam`** | 25 | 4.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`**).
@ -89,12 +97,14 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
**NPC runtime snapshot HTTP (NEO-94):** **`GET /game/world/npc-runtime-snapshot`** — versioned read-only projection (`schemaVersion` **1**, **`npcInstances`**, optional nested **`activeTelegraph`**) with lazy **`AdvanceAll`** on poll. Plan: [NEO-94 implementation plan](../../plans/NEO-94-implementation-plan.md); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`.
**Session player combat HP (NEO-95):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** in `server/NeonSprawl.Server/Game/Combat/` — telegraph complete applies catalog **`attackDamage`** to aggro holder; **`GET /game/players/{id}/combat-health`** read model for client HUD. Plan: [NEO-95 implementation plan](../../plans/NEO-95-implementation-plan.md); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`.
**Session player combat HP (NEO-95 / NEO-98):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** in `server/NeonSprawl.Server/Game/Combat/` — telegraph complete looks up behavior **`attackAbilityId`**, applies ability catalog **`baseDamage`** when the aggro holder is within that ability's **`maxRange`** (horizontal X/Z); out-of-range windups whiff. Behavior row **`attackDamage`** is projection + CI cross-ref only (not the resolve source). **`TryStopOnTargetDefeat`** on cast accept + **`AdvanceOne`** defeated guard clears aggro/runtime when the NPC combat-target row is defeated (NEO-98). **`GET /game/players/{id}/combat-health`** read model for client HUD. Plan: [NEO-95 implementation plan](../../plans/NEO-95-implementation-plan.md); [NEO-98 implementation plan](../../plans/NEO-98-implementation-plan.md); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`.
**NPC runtime telemetry hooks (NEO-96):** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** (`TODO(E9.M1)`; no ingest). Plan: [NEO-96 implementation plan](../../plans/NEO-96-implementation-plan.md); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96).
**Client telegraph HUD (NEO-97):** Godot poll of **`npc-runtime-snapshot`** + **`combat-health`**; **`TelegraphLabel`**, **`NpcStateLabel`**, **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll gate. Plan: [NEO-97 implementation plan](../../plans/NEO-97-implementation-plan.md); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97); manual QA [NEO-97](../../manual-qa/NEO-97.md).
**Playable NPC telegraph combat capstone (NEO-98):** single-session Godot manual QA covering melee telegraph spine + three-archetype defeat — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). Plan: [NEO-98 implementation plan](../../plans/NEO-98-implementation-plan.md). **Server integration (capstone QA):** [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json), behavior **`attackAbilityId`**, range-gated telegraph resolve, defeat clears aggro/runtime. **Epic 5 Slice 2 client capstone complete.**
**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

View File

@ -7,7 +7,8 @@
| **Module ID** | E5.M3 |
| **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 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) decomposed |
| **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
## Purpose
@ -28,8 +29,9 @@ Encounter templates, completion criteria, and reward drop routing into items ([E
## Module dependencies
- **E5.M1** — CombatRulesEngine.
- **E5.M2** — NpcAiAndBehaviorProfiles (defeat targets).
- **E3.M3** — ItemizationAndInventorySchema.
- **E7.M2** — RewardAndUnlockRouter.
- **E7.M2** — RewardAndUnlockRouter (quest credit consumer; prototype uses quest-token loot + hook stub).
## Dependents (by design)
@ -41,6 +43,41 @@ Encounter templates, completion criteria, and reward drop routing into items ([E
Epic 5 **Slice 3**`encounter_complete`, reward attribution once.
## Linear backlog (decomposed)
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md).
| Slug | Linear |
|------|--------|
| E5M3-01 | [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) |
| E5M3-02 | [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) |
| E5M3-03 | [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) |
| E5M3-04 | [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) |
| E5M3-05 | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) |
| E5M3-06 | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) |
| E5M3-07 | [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) |
| E5M3-08 | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) |
| E5M3-09 | [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) |
| E5M3-10 | [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) |
| E5M3-11 | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) |
| E5M3-12 | [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
## Prototype Slice 3 freeze (E5M3-01)
The **first shipped encounter + reward spine** is **frozen** for prototype tuning until a deliberate migration issue expands the roster.
| `EncounterDef.id` | `displayName` | `completionCriteria.kind` | `requiredNpcInstanceIds` | `rewardTableId` |
|-------------------|---------------|---------------------------|--------------------------|-----------------|
| **`prototype_combat_pocket`** | Prototype Combat Pocket | **`defeat_all_targets`** | **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** | **`prototype_combat_pocket_clear`** |
| `RewardTable.id` | `fixedGrants` |
|------------------|---------------|
| **`prototype_combat_pocket_clear`** | **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** |
**CI enforcement (NEO-100):** `scripts/validate_content.py` requires **exactly** encounter id **`prototype_combat_pocket`** and reward table id **`prototype_combat_pocket_clear`**; **`requiredNpcInstanceIds`** set must match server **`PrototypeNpcRegistry`** (`prototype_npc_melee`, `prototype_npc_ranged`, `prototype_npc_elite`); **`fixedGrants`** item ids from frozen item catalog with **no duplicate `itemId` per table**, quantities **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**; encounter **`rewardTableId`** cross-ref. Keep **`PROTOTYPE_E5M3_*`** and **`PROTOTYPE_E5M2_NPC_INSTANCE_IDS`** in sync with this table and [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server loader. Plan: [NEO-100 implementation plan](../../plans/NEO-100-implementation-plan.md).
**Payout policy:** Per-defeat **gig XP** stays on [NEO-44](../../plans/NEO-44-implementation-plan.md). Encounter complete grants **loot + quest token** **once** per player per encounter id. Full **E7.M2** quest credit routing consumes **`EncounterCompleteEvent`** (E5M3-09 hook stub).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.

File diff suppressed because one or more lines are too long

View File

@ -73,12 +73,14 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---|
| 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.M3 | EncounterAndRewardTables | E5.M1, E5.M2, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | In Progress |
| 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 init (Slice 1 flat 100 HP; **superseded by [NEO-91 plan](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **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/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
**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/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
**E5.M3 note:** Epic 5 **Slice 3** 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-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **Server load/runtime (NEO-101+) not started.**
### Epic 6 — PvP Security

View File

@ -0,0 +1,88 @@
# NEO-98 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-98 |
| Title | E5M2-12: Playable NPC telegraph combat capstone (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-98/e5m2-12-playable-npc-telegraph-combat-capstone-godot |
| Plan | `docs/plans/NEO-98-implementation-plan.md` |
| Branch | `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** NPC HP, aggro/runtime rows, and player combat HP reset.
- **No Bruno/curl seeding** for this checklist — hotbar **`prototype_pulse`** bind is automatic (NEO-85 dev bootstrap).
- NEO-97 telegraph HUD landed on `main` (PR #136).
- **`CombatTargetHpLabel`** uses NEO-85 format **`Target HP: {id} {current}/{max}`** (e.g. **`Target HP: prototype_npc_melee 100/100`**) — match HP substring or full line.
## Archetype reference
| Archetype | Instance id | Marker | maxHp | Pulses to defeat | NPC damage | Strike range | Windup | Cooldown |
|-----------|-------------|--------|-------|------------------|------------|--------------|--------|----------|
| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | **2 m** (close) | 1.5 s | 3.0 s |
| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 10 m | 2.0 s | 4.0 s |
| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | **4 m** | 2.5 s | 5.0 s |
Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown).
## Melee defeat 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 |
## Checklist
### Boot
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
2. Run Godot main scene (**F5**). Confirm slot **0** auto-binds **`prototype_pulse`** after hotbar sync.
### Melee — full telegraph + player HP spine
3. Walk to orange **Melee** marker west of spawn **`(-3, -3)`**.
4. Press **Tab** until **`prototype_npc_melee`** is locked with **`Validity: ok`** — marker **brightens**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_npc_melee 100/100`** (or **`…`** briefly until GET completes).
5. Press **1** (damaging cast): **`CastFeedbackLabel`** shows damage; **`NpcStateLabel`** eventually shows **`NPC state: Melee → aggro`** (~1 Hz poll).
6. **`PlayerCombatHpLabel`** shows **`Player HP: 100/100`** once combat poll starts.
7. Wait **≥ 3 s** from first cast: **`NpcStateLabel`** shows **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (e.g. **`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls.
8. Wait **≥ 4.5 s** total from first cast (windup completes): stay **within ~2 m** of the melee marker (close combat — tab-lock is 6 m but strike range is **2 m**). **`PlayerCombatHpLabel`** drops to **`85/100`** (melee **15** damage). Back off during windup → telegraph may finish but **no** player damage (whiff).
9. Press **1** three more times (respect ~3 s cooldown) until **`CombatTargetHpLabel`** shows **`0/100 (defeated)`** per defeat table above.
10. Press **1** again: **`ability_cast_denied: target_defeated`**.
### Ranged — defeat + telegraph visible
11. Walk to purple **Ranged** marker south-east **`(3, 3)`**; **Tab** lock **`prototype_npc_ranged`**.
12. Press **1** (first damaging cast) → **`NpcStateLabel`** shows **`aggro`**.
13. Wait **≥ 4 s** from first cast: **`telegraph_windup`** + readable **`TelegraphLabel`** (2.0 s windup after 4.0 s cooldown).
14. Press **1** until defeated (**4** pulses total); verify **`0/80 (defeated)`** on **`CombatTargetHpLabel`** and deny on re-cast.
### Elite — defeat + telegraph visible
15. Walk to gold **Elite** marker at origin **`(0, 0)`**; **Tab** lock **`prototype_npc_elite`**.
16. Press **1** (first damaging cast) → **`NpcStateLabel`** shows **`aggro`**.
17. Wait **≥ 5 s** from first cast: **`telegraph_windup`** + readable **`TelegraphLabel`** (2.5 s windup after 5.0 s cooldown).
18. Press **1** until defeated (**8** pulses total); verify **`0/200 (defeated)`** on **`CombatTargetHpLabel`** and deny on re-cast.
### Regression
19. Press **Esc** to clear lock: **`CombatTargetHpLabel`** shows **`Target HP: — (Tab → elite/melee/ranged near spawn)`** (or equivalent no-lock line).
20. Cast without lock: **`CastFeedbackLabel`** shows **`ability_cast_denied: invalid_target`** (NEO-28).
21. **`CombatTargetHpLabel`** + cooldown HUD still refresh after accepted casts (NEO-85 / NEO-32) — re-lock any NPC and press **1** once to confirm.
## Notes
- Capstone baseline prefers **server restart** over mid-session fixture POST.
- Optional reset (Development host): `POST /game/__dev/combat-targets-fixture` — see [NEO-97 manual QA](NEO-97.md) / [server README — dev fixture](../../server/README.md#dev-combat-target-fixture-brunomanual-qa).
- Component-level telegraph HUD regression (elite incoming-threat row): [NEO-97 manual QA](NEO-97.md).
- Slice 1 combat capstone (gig XP): [NEO-86 manual QA](NEO-86.md).
## Acceptance
- [ ] Steps 121 completable in one session without Bruno/curl.
- [ ] Melee telegraph countdown readable **before** player HP drops to **85/100** (steps 78).
- [ ] All three archetypes show **`defeated`** in **`CombatTargetHpLabel`** (steps 9, 14, 18).
- [ ] Aggro on first damaging cast feels deterministic (no silent failures).

View File

@ -1,6 +1,6 @@
# E5.M1 — Prototype story backlog (CombatRulesEngine)
Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp)). Decomposition and contracts: [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md).
Working backlog for **Epic 5 — Slice 1** ([Epic 5 · Slice 1 — combat rules MVP](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-1)). Decomposition and contracts: [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md).
**Labels (Linear):** every issue **`E5.M1`**; add **`server`** or **`client`** + **`Story`** as listed.
@ -287,7 +287,7 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
**Acceptance criteria**
- [x] Hook names match [epic_05 Slice 1](../decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp) vocabulary.
- [x] Hook names match [epic_05 Slice 1](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-1) 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).
@ -368,7 +368,7 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
- [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.
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-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.

View File

@ -1,6 +1,6 @@
# 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).
Working backlog for **Epic 5 — Slice 2** ([Epic 5 · Slice 2 — NPC archetypes and telegraphs](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-2)). 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).
@ -69,11 +69,13 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
| 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 |
| NPC attacks | **Deterministic** damage from behavior **`attackAbilityId`** → ability catalog **`baseDamage`** when holder is within ability **`maxRange`** after telegraph resolves | Matches E5.M1 no-RNG combat; behavior **`attackDamage`** = projection + CI cross-ref only ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)) |
| 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 |
**Resolve source ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)):** telegraph complete uses **`attackAbilityId`** + NPC ability catalog (**`baseDamage`**, **`maxRange`**), not behavior **`attackDamage`**. **`attackDamage`** remains on the behavior row for HTTP projection and CI cross-ref only.
---
### E5M2-01 — Prototype NpcBehaviorDef catalog + schemas + CI
@ -280,7 +282,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
### 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.
**Goal:** When telegraph completes, resolve via behavior **`attackAbilityId`** → ability catalog **`baseDamage`** when aggro holder is within ability **`maxRange`**; apply to holder via **`IPlayerCombatHealthStore`**; expose player HP on snapshot or dedicated GET. Behavior **`attackDamage`** is projection/CI cross-ref only (**[NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)** superseded direct **`attackDamage`** resolve).
**In scope**
@ -301,7 +303,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
- [x] Player HP read model matches store after NPC attack.
- [x] No client-side damage math required for verification (Bruno or integration tests).
**Landed ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph complete applies catalog damage; **`GET /game/players/{id}/combat-health`**; plan [NEO-95-implementation-plan.md](NEO-95-implementation-plan.md).
**Landed ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph complete applies catalog damage; **`GET /game/players/{id}/combat-health`**; plan [NEO-95-implementation-plan.md](NEO-95-implementation-plan.md). **Resolve source superseded by [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98):** **`attackAbilityId`** + ability **`baseDamage`** / **`maxRange`** (behavior **`attackDamage`** = projection/CI cross-ref only).
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — player HP + incoming telegraph/attack feedback labels.
@ -322,7 +324,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria**
- [x] Hook names match [epic_05 Slice 2](../decomposition/epics/epic_05_pve_combat.md#slice-2---npc-archetypes-and-telegraphs) vocabulary.
- [x] Hook names match [epic_05 Slice 2](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-2) vocabulary.
- [x] No runtime behavior change beyond comments.
**Landed ([NEO-96](https://linear.app/neon-sprawl/issue/NEO-96)):** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations`**; plan [NEO-96-implementation-plan.md](NEO-96-implementation-plan.md).
@ -375,9 +377,12 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**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.
- [x] Human completes script with server + client; telegraph timing readable before damage.
- [x] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA.
- [x] All three archetypes defeated in one session; **`CombatTargetHpLabel`** shows defeated for each (manual QA steps 9, 14, 18).
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums.
**Landed ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)):** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section; plan [NEO-98-implementation-plan.md](NEO-98-implementation-plan.md). **Epic 5 Slice 2 client capstone complete.**
**Client counterpart:** this issue (**NEO-98**).
@ -385,7 +390,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
## After this backlog
- **[E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)** Slice 3 — decompose when E5.M2 lands (encounters + loot routing).
- **[E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)** Slice 3 — decomposed in [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) (**NEO-100** → **NEO-111**).
- 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.

View File

@ -0,0 +1,377 @@
# E5.M3 — Prototype story backlog (EncounterAndRewardTables)
Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters and loot routing](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3)). Decomposition and contracts: [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.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: [E5M2 (paired server+client)](../plans/E5M2-prototype-backlog.md).
**Labels (Linear):** every issue **`E5.M3`**; add **`server`** or **`client`** + **`Story`** as listed.
**Precursor (do not re-scope):** [E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) NPC combat pocket — three NPC instance ids (**`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`**) with defeat via **`CombatOperations`** ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)). Per-defeat **gig XP** stays on [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) — encounter complete adds **loot + quest-credit stand-in**, not a second gig XP bundle.
**Upstream (must be landed):** [E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) **Ready**; [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) inventory **`PlayerInventoryOperations`** ([NEO-54](https://linear.app/neon-sprawl/issue/NEO-54)[NEO-55](https://linear.app/neon-sprawl/issue/NEO-55)). **E7.M2** full **`QuestRewardBundle`** router is **not** required — prototype uses **`contract_handoff_token`** loot + **`EncounterCompleteEvent`** hook for future quest credit ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) pattern).
**Prototype encounter spine (frozen in E5M3-01):** **one** `EncounterDef` — id **`prototype_combat_pocket`** — completion **`defeat_all_targets`** over the three E5.M2 NPC instance ids. **One** `RewardTable` — id **`prototype_combat_pocket_clear`** — deterministic fixed item grants (no RNG).
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
| Slug | Layer | Linear |
|------|-------|--------|
| E5M3-01 | server | [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) |
| E5M3-02 | server | [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) |
| E5M3-03 | server | [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) |
| E5M3-04 | server | [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) |
| E5M3-05 | server | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) |
| E5M3-06 | server | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) |
| E5M3-07 | server | [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) |
| E5M3-08 | server | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) |
| E5M3-09 | server | [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) |
| E5M3-10 | server | [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) |
| E5M3-11 | client | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) |
| E5M3-12 | client | [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
**Dependency graph in Linear:** E5M3-02 blocked by E5M3-01. E5M3-03 blocked by E5M3-02. E5M3-04 blocked by E5M3-03. E5M3-05 blocked by E5M3-04. E5M3-06 blocked by E5M3-05. E5M3-07 blocked by E5M3-06. E5M3-08 blocked by E5M3-07. E5M3-09 blocked by E5M3-06 (may parallel E5M3-08). E5M3-10 blocked by E5M3-07. E5M3-11 blocked by E5M3-08 and E5M3-09. E5M3-12 blocked by E5M3-11. *(Linear ids: E5M3-08 = **NEO-108**, E5M3-09 = **NEO-107** — creation order swap.)*
**Board order:** estimates **112** matching slug order (E5M3-01 = 1 … E5M3-12 = 12). On the Epic 5 project board, sort by **Estimate** (ascending).
---
## Story order (recommended)
| Order | Slug | Layer | Depends on |
|-------|------|-------|------------|
| 1 | **E5M3-01** | server | E5.M2 Ready, E3.M3 inventory landed |
| 2 | **E5M3-02** | server | E5M3-01 |
| 3 | **E5M3-03** | server | E5M3-02 |
| 4 | **E5M3-04** | server | E5M3-03 |
| 5 | **E5M3-05** | server | E5M3-04 |
| 6 | **E5M3-06** | server | E5M3-05 |
| 7 | **E5M3-07** | server | E5M3-06 |
| 8 | **E5M3-08** | server | E5M3-07 |
| 9 | **E5M3-09** | server | E5M3-06 |
| 10 | **E5M3-10** | server | E5M3-07 |
| 11 | **E5M3-11** | client | E5M3-08, E5M3-09 |
| 12 | **E5M3-12** | client | E5M3-11 |
**Downstream (separate modules):** [E5.M4](../decomposition/modules/E5_M4_GroupCombatScaling.md) group scaling; [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) / [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) consume **`EncounterCompleteEvent`**; [E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md) spawn ecology.
---
## Kickoff decisions (decomposition defaults)
| Topic | Decision | Rationale |
|-------|----------|-----------|
| Encounter count | **1** frozen `EncounterDef` | Vision prototype minimum; epic Slice 3 |
| Completion rule | **`defeat_all_targets`** over three E5.M2 NPC ids | Reuses static registry; order-independent |
| Reward table | **Deterministic fixed grants** (no RNG rolls) | Readable tests; RNG is pre-production |
| Frozen loot | **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** | Material + quest-token stand-in for E7.M2 credit |
| Idempotency | **Once per player per `encounterId`** | Slice 3 AC: grants expected payout **once** |
| Gig XP | **Keep [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) per-defeat** | Combat → gig XP is separate from encounter loot bundle |
| Quest credit | **`contract_handoff_token`** item + **`EncounterCompleteEvent`** record | Full E7.M2 router deferred; visible in inventory HUD |
| Encounter start | **Auto** on first damaging cast vs any required NPC | Activates progress tracking; **`encounter_start`** hook site |
| Progress store | **In-memory** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** | Session prototype; Postgres optional follow-on |
| HTTP read | **`GET /game/players/{id}/encounter-progress`** | Client HUD without trusting local defeat math |
| HUD fidelity | Prototype Labels / debug panels (not final art) | NEO-110 precedent |
---
### E5M3-01 — Prototype EncounterDef + RewardTable catalog + schemas + CI
**Goal:** Lock content shape and CI validation for **one** frozen encounter + **one** reward table before server load.
**In scope**
- `content/schemas/encounter-def.schema.json`, `content/schemas/reward-table.schema.json`, `content/schemas/reward-grant-row.schema.json`.
- `content/encounters/prototype_encounters.json` — stable **`id`**: **`prototype_combat_pocket`**; **`completionCriteria.kind`**: **`defeat_all_targets`**; **`requiredNpcInstanceIds`**: three E5.M2 NPC ids; **`rewardTableId`**: **`prototype_combat_pocket_clear`**.
- `content/reward-tables/prototype_reward_tables.json` — stable **`id`**: **`prototype_combat_pocket_clear`**; **`fixedGrants`**: **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**.
- `scripts/validate_content.py`: schema validation, duplicate ids, exact one-encounter / one-table allowlist, cross-ref **`itemId`** → frozen item catalog, **`requiredNpcInstanceIds`** → frozen NPC instance ids, **`rewardTableId`** → reward table ids.
- Designer note in [E5_M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) + `content/README.md`.
**Out of scope**
- Server loader, runtime engine, HTTP, Godot.
**Acceptance criteria**
- [x] PR gate validates encounter + reward-table JSON against schema.
- [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI.
- [x] Stable id list documented in module doc freeze box.
**Landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates in `validate_content.py`; plan [NEO-100-implementation-plan.md](NEO-100-implementation-plan.md).
---
### E5M3-02 — Server encounter + reward catalog load (fail-fast)
**Goal:** Disk → host: startup load of `content/encounters/*.json` and `content/reward-tables/*.json` with CI-parity validation.
**In scope**
- Loader + catalog types under `server/NeonSprawl.Server/Game/Encounters/` (path finalized in plan).
- Fail-fast on malformed files, duplicate ids, allowlist mismatch with CI, broken cross-refs.
- Unit tests (AAA) for loader happy path and failure modes.
- `server/README.md` section.
**Out of scope**
- Injectable registry interface (E5M3-03), HTTP projection, runtime completion.
**Acceptance criteria**
- [ ] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules).
- [ ] Tests cover at least one happy path and one malformed catalog rejection.
---
### E5M3-03 — Injectable encounter/reward registries + DI
**Goal:** Replace direct catalog access with injectable registries; wire into host DI.
**In scope**
- `IEncounterDefinitionRegistry`, `IRewardTableDefinitionRegistry` + implementations backed by E5M3-02 catalogs.
- `TryNormalizeKnown` / list-in-id-order parity with ability/item registry patterns.
- DI extension method; unit tests (AAA).
**Out of scope**
- HTTP routes, progress store, client.
**Acceptance criteria**
- [ ] Registries resolve frozen encounter + reward table ids.
- [ ] Unknown id normalization fails closed.
---
### E5M3-04 — GET `/game/world/encounter-definitions`
**Goal:** Read-only world projection of encounter defs (+ nested or linked reward table summary) for client labels and Bruno smokes.
**In scope**
- `EncounterDefinitionsWorldApi` + versioned DTOs in `Game/Encounters/`.
- Integration tests (AAA); Bruno folder `bruno/neon-sprawl-server/encounter-definitions/`.
- `server/README.md` route section.
**Out of scope**
- Per-player progress, completion grants, Godot.
**Acceptance criteria**
- [ ] GET returns frozen encounter in ascending `id` order with `schemaVersion` **1**.
- [ ] Bruno happy GET passes in CI Bruno step.
---
### E5M3-05 — Encounter progress + completion stores
**Goal:** Server-owned per-player encounter activation, defeat progress, and idempotent completion flag.
**In scope**
- `IEncounterProgressStore` — tracks **`started`**, defeated subset of **`requiredNpcInstanceIds`**, keyed by **`(playerId, encounterId)`**.
- `IEncounterCompletionStore` — records **`completedAt`** (or boolean) once rewards applied; replays are no-op.
- `EncounterProgressOperations.TryMarkTargetDefeated` / `TryActivateOnFirstEngagement` (first damaging cast vs required NPC).
- Unit tests (AAA) with injected stores.
**Out of scope**
- Inventory grants (E5M3-06), HTTP DTOs (E5M3-08), Godot.
**Acceptance criteria**
- [ ] Defeating required NPCs accumulates progress in any order.
- [ ] Completion predicate true only when all required ids defeated.
- [ ] Second completion attempt on same player+encounter is idempotent no-op at store layer.
---
### E5M3-06 — EncounterCompletionOperations + inventory reward apply
**Goal:** On first completion, apply **`RewardTable.fixedGrants`** via **`PlayerInventoryOperations`**; emit internal **`EncounterCompleteEvent`** payload for downstream routers.
**In scope**
- `EncounterCompletionOperations.TryCompleteAndGrant` — checks completion store, applies fixed grants, marks complete atomically (best-effort rollback on inventory deny documented in plan).
- Structured deny reason codes (`inventory_full`, `already_completed`, `not_ready`, …).
- Integration tests: defeat all targets → inventory stacks increase exactly once.
- Bruno spine optional in plan (may land in E5M3-08).
**Out of scope**
- Godot HUD — **client counterpart [NEO-110](#)**.
- Full E7.M2 quest router (E5M3-09 hook stub).
**Acceptance criteria**
- [ ] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically.
- [ ] Second completion does not duplicate grants.
- [ ] Inventory deny fails closed without marking complete.
**Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels.
---
### E5M3-07 — Wire combat defeat → encounter progress/completion
**Goal:** Hook **`AbilityCastApi`** (or shared combat defeat helper) so NPC **`targetDefeated`** updates encounter progress and invokes E5M3-06 when the encounter completes.
**In scope**
- Call **`EncounterProgressOperations`** after successful player → NPC defeat ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) path).
- **`TryActivateOnFirstEngagement`** on first damaging cast vs encounter NPC ( **`encounter_start`** comment hook site).
- Integration tests: Bruno or in-proc defeat-all-three → completion + inventory grant once.
- Preserve existing **NEO-44** gig XP on each defeat (do not remove).
**Out of scope**
- Godot presentation (E5M3-11).
- Quest router beyond completion event record (E5M3-09).
**Acceptance criteria**
- [ ] Defeating third required NPC triggers exactly one completion grant.
- [ ] Partial progress (12 defeats) does not grant loot.
- [ ] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`**.
---
### E5M3-08 — GET `/game/players/{id}/encounter-progress` + wire DTOs
**Goal:** Authoritative per-player encounter rows for client poll / event refresh.
**In scope**
- `EncounterProgressApi` + DTOs: per encounter **`state`** (`inactive` / `active` / `completed`), **`defeatedTargetIds`**, **`completedAt`**, last **`rewardGrantSummary`** (item ids + quantities on complete).
- Integration tests (AAA); Bruno `bruno/neon-sprawl-server/encounter-progress/`.
- `server/README.md` section.
**Out of scope**
- Godot poll client (E5M3-11).
**Acceptance criteria**
- [ ] GET reflects progress after each defeat without client-side inference.
- [ ] Completed encounter shows **`completed`** state and grant summary.
- [ ] Bruno smoke: defeat chain → GET shows **`completed`** once.
**Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — poll/refresh from this GET.
---
### E5M3-09 — EncounterCompleteEvent + E7.M2 quest-credit hook stub
**Goal:** Persist **`EncounterCompleteEvent`** record and document **E7.M2** consumer hook for quest credit (beyond quest-token item loot).
**In scope**
- `EncounterCompleteEvent` internal type (playerId, encounterId, idempotencyKey, grantedItemSummary, completedAt).
- Comment-only **`reward_delivery`** / quest-credit hook site in completion path (TODO E7.M2).
- `server/README.md` pointer; module doc cross-link to [E7_M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md).
**Out of scope**
- Production quest state machine, Godot.
**Acceptance criteria**
- [ ] Completion path emits/records event exactly once per player+encounter.
- [ ] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract.
- [ ] No runtime behavior beyond event record + comments.
---
### E5M3-10 — Slice 3 encounter telemetry hook sites
**Goal:** Comment-only **`encounter_complete`** and **`reward_attribution`** hook sites in encounter completion path (activates reserved **`encounter_start`** from E5M3-07).
**In scope**
- TODO(E9.M1) markers at encounter start, completion commit, and per-item grant attribution.
- `server/README.md` pointer; epic Slice 3 vocabulary cross-link.
**Out of scope**
- Production ingest, Godot.
**Acceptance criteria**
- [ ] Hook names match [epic_05 Slice 3](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3) vocabulary.
- [ ] No runtime behavior change beyond comments.
---
### E5M3-11 — Client encounter progress + loot feedback HUD
**Goal:** Godot shows **server-owned** encounter progress and completion loot using encounter-progress GET.
**In scope**
- `client/scripts/encounter_progress_client.gd` — poll or event-driven **`GET /game/players/{id}/encounter-progress`** after combat defeats.
- HUD labels: **`EncounterProgressLabel`**, **`EncounterCompleteLabel`** under `UICanvas`; refresh **`InventoryLabel`** on completion (reuse [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) client).
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- `docs/manual-qa/NEO-110.md`**Godot** steps (server + client running).
- `client/README.md` encounter section.
**Out of scope**
- Final loot VFX art; encounter selection UI beyond single frozen encounter.
**Acceptance criteria**
- [ ] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets).
- [ ] Completion label shows grant summary matching server GET.
- [ ] Manual QA checklist exercisable without Bruno.
**Client counterpart:** this issue (**NEO-110**).
---
### E5M3-12 — Playable encounter clear capstone (Godot)
**Goal:** Prove Epic 5 Slice 3 acceptance **in Godot**: defeat all three NPCs, receive loot + quest token **once**, verify inventory — without Bruno.
**In scope**
- `docs/manual-qa/NEO-111.md` single-session script (includes idempotency check — no duplicate loot on re-login / fresh defeat attempt after complete).
- `client/README.md` end-to-end encounter loop section.
- Update [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M3 row when complete.
**Out of scope**
- Group scaling ([E5.M4](../decomposition/modules/E5_M4_GroupCombatScaling.md)), dynamic spawn ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
**Acceptance criteria**
- [ ] Human completes script with server + client; loot readable in inventory HUD.
- [ ] Epic 5 Slice 3 AC: completing defined encounter grants expected items + quest token **once**.
- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums.
**Client counterpart:** this issue (**NEO-111**).
---
## After this backlog
- 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-110**, **NEO-111**)
- [x] No “optional follow-up” / “future client” without NEO-XX
- [x] Capstone client issue exists for playable Godot verification (**NEO-111**)
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) updated (decomposed row)
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E5.M3 note updated when issues are created
## Related docs
- [E5_M3_EncounterAndRewardTables.md](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)
- [E5_M2_NpcAiAndBehaviorProfiles.md](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md)
- [E3_M3_ItemizationAndInventorySchema.md](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md)
- [E7_M2_RewardAndUnlockRouter.md](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md)
- [epic_05_pve_combat.md](../decomposition/epics/epic_05_pve_combat.md)
- [client_server_authority.md](../decomposition/modules/client_server_authority.md)

View File

@ -0,0 +1,174 @@
# NEO-100 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-100 |
| **Title** | E5M3-01: Prototype EncounterDef + RewardTable catalog + schemas + CI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-100/e5m3-01-prototype-encounterdef-rewardtable-catalog-schemas-ci |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-01** |
| **Branch** | `NEO-100-e5m3-encounter-reward-catalog-schemas-ci` |
| **Precursor** | [E5.M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) **Ready** (three NPC instance ids) · [E3.M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) **Ready** (frozen item catalog incl. **`contract_handoff_token`**) |
| **Pattern** | [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) — row JSON Schema + catalog envelope + `scripts/validate_content.py` gate |
| **Blocks** | [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) — server encounter + reward catalog load |
## Kickoff clarifications
**No clarifications needed.** Scope, frozen ids, loot grants, completion rule, and cross-ref targets are fully specified in [E5M3 kickoff decisions](E5M3-prototype-backlog.md#kickoff-decisions-decomposition-defaults), [E5.M3 freeze table](../decomposition/modules/E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01), and `content/README.md` (NEO-100 paragraph already present from E5M3 decomposition). Catalog envelope and CI constant naming follow NEO-87 / NEO-76 precedent without ambiguity.
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| — | — | — | **No blocking decisions** (see above). |
## Goal, scope, and out-of-scope
**Goal:** Lock content shape and CI validation for **one** frozen `EncounterDef` + **one** frozen `RewardTable` before server load (NEO-101+).
**In scope (from Linear + [E5M3-01](E5M3-prototype-backlog.md#e5m3-01--prototype-encounterdef--rewardtable-catalog--schemas--ci)):**
- `content/schemas/encounter-def.schema.json`, `content/schemas/reward-table.schema.json`, `content/schemas/reward-grant-row.schema.json`.
- `content/encounters/prototype_encounters.json` — one row **`prototype_combat_pocket`**.
- `content/reward-tables/prototype_reward_tables.json` — one row **`prototype_combat_pocket_clear`**.
- `scripts/validate_content.py` — schema validation, duplicate `id`, one-encounter / one-table allowlists, cross-ref **`itemId`** → frozen item catalog, **`requiredNpcInstanceIds`** → three E5.M2 NPC instance ids, **`rewardTableId`** → reward table ids.
- Designer note / CI rules line in [E5_M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) + **CT.M1** PR gate paragraph.
- `documentation_and_implementation_alignment.md` E5.M3 row → note NEO-100 catalog in progress / landed.
**Out of scope (from Linear):**
- Server loader, runtime engine, HTTP, Godot (NEO-101+).
- **`IEncounterProgressStore`**, completion events, inventory grant (E5M3-04+).
- **Client counterpart:** none — server-only content + CI; player-visible work starts at E5M3-08+ / [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) / [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111).
## Acceptance criteria checklist
- [x] PR gate validates encounter + reward-table JSON against schema.
- [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI.
- [x] Stable id list documented in module doc freeze box.
## Implementation reconciliation (shipped)
- **Schemas:** [`encounter-def.schema.json`](../../content/schemas/encounter-def.schema.json), [`reward-table.schema.json`](../../content/schemas/reward-table.schema.json), [`reward-grant-row.schema.json`](../../content/schemas/reward-grant-row.schema.json).
- **Catalogs:** [`prototype_encounters.json`](../../content/encounters/prototype_encounters.json), [`prototype_reward_tables.json`](../../content/reward-tables/prototype_reward_tables.json).
- **CI:** `PROTOTYPE_E5M3_ENCOUNTER_IDS`, `PROTOTYPE_E5M3_REWARD_TABLE_IDS`, `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`, `PROTOTYPE_E5M3_REWARD_GRANTS` gates in [`validate_content.py`](../../scripts/validate_content.py); duplicate **`fixedGrants[].itemId`** rejected per reward table; **`requiredNpcInstanceIds`** schema **`uniqueItems`**.
- **Docs:** E5.M3 CI enforcement line, CT.M1, alignment register, module dependency register, E5M3-01 backlog checkboxes.
- **`content/README.md`:** verified against shipped ids and cross-ref rules — no edit required (paragraph pre-landed in E5M3 decomposition).
## Technical approach
1. **`reward-grant-row.schema.json`:** Mirror [`recipe-io-row.schema.json`](../../content/schemas/recipe-io-row.schema.json) — required **`itemId`** (pattern `^[a-z][a-z0-9_]*$`), **`quantity`** integer ≥ 1.
2. **`reward-table.schema.json`:** Draft 2020-12 object; `additionalProperties: false`. Required: **`id`**, **`displayName`**, **`fixedGrants`** (non-empty array of `$ref` reward-grant-row). `id` pattern same as other catalogs.
3. **`encounter-def.schema.json`:** Required: **`id`**, **`displayName`**, **`completionCriteria`**, **`requiredNpcInstanceIds`**, **`rewardTableId`**. **`completionCriteria`:** object with required **`kind`** enum **`defeat_all_targets`** only (prototype). **`requiredNpcInstanceIds`:** array of 3 strings (pattern), **`minItems`/`maxItems`: 3**. **`rewardTableId`:** string pattern.
4. **Catalog files:**
**`content/reward-tables/prototype_reward_tables.json`**
```json
{
"schemaVersion": 1,
"rewardTables": [
{
"id": "prototype_combat_pocket_clear",
"displayName": "Prototype Combat Pocket Clear",
"fixedGrants": [
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
{ "itemId": "contract_handoff_token", "quantity": 1 }
]
}
]
}
```
**`content/encounters/prototype_encounters.json`**
```json
{
"schemaVersion": 1,
"encounters": [
{
"id": "prototype_combat_pocket",
"displayName": "Prototype Combat Pocket",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite"
],
"rewardTableId": "prototype_combat_pocket_clear"
}
]
}
```
**`requiredNpcInstanceIds`** order matches [E5.M3 freeze table](../decomposition/modules/E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01) (melee → ranged → elite); CI enforces **set equality** with `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`, not order-sensitive beyond schema min/max 3.
5. **`validate_content.py` constants (keep in sync with E5.M3 module doc + future NEO-101 loader):**
| Constant | Value |
|----------|--------|
| `PROTOTYPE_E5M3_ENCOUNTER_IDS` | `{ "prototype_combat_pocket" }` |
| `PROTOTYPE_E5M3_REWARD_TABLE_IDS` | `{ "prototype_combat_pocket_clear" }` |
| `PROTOTYPE_E5M2_NPC_INSTANCE_IDS` | `{ "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" }` |
6. **`validate_content.py` validators:**
- Add schema paths + dirs: `ENCOUNTER_DEF_SCHEMA`, `REWARD_TABLE_SCHEMA`, `REWARD_GRANT_ROW_SCHEMA`, `ENCOUNTERS_DIR`, `REWARD_TABLES_DIR`.
- **`_validate_reward_table_catalogs`:** envelope `schemaVersion: 1`, top-level **`rewardTables`** array; validate each row; track `seen_ids`; reject duplicates across files.
- **`_prototype_e5m3_reward_table_gate`:** ids must equal `PROTOTYPE_E5M3_REWARD_TABLE_IDS`.
- **`_prototype_e5m3_reward_grant_item_gate`:** each `fixedGrants[].itemId``PROTOTYPE_SLICE1_ITEM_IDS`; quantities ≥ 1 (schema) — spot-check **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** in gate or integration-style assert in docstring test notes.
- **`_validate_encounter_catalogs`:** envelope `schemaVersion: 1`, top-level **`encounters`** array; validate rows; duplicate `id` rejection.
- **`_prototype_e5m3_encounter_gate`:** ids must equal `PROTOTYPE_E5M3_ENCOUNTER_IDS`.
- **`_prototype_e5m3_encounter_cross_ref_gate`:** each row's **`requiredNpcInstanceIds`** set must equal `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`; **`rewardTableId`** must exist in reward-table `seen_ids`.
- Run **reward tables before encounters** in `main()` (encounters reference reward table ids).
- Extend module docstring, startup schema file checks, success summary with encounter + reward-table file/id counts.
7. **Docs:**
- `E5_M3_EncounterAndRewardTables.md` — add CI enforcement line under freeze table (ids + cross-ref rules); link to this plan when complete.
- `CT_M1_ContentValidationPipeline.md` — encounter + reward-table PR gate paragraph.
- `documentation_and_implementation_alignment.md` — E5.M3 row notes NEO-100 catalog when complete.
- `content/README.md` — table rows for `encounters/` and `reward-tables/` already present; verify paragraph matches shipped constants after implementation.
- `E5M3-prototype-backlog.md` — E5M3-01 checkboxes when implementation completes.
8. **No server/C#/client changes** in this story.
## Files to add
| Path | Purpose |
|------|---------|
| `content/schemas/reward-grant-row.schema.json` | JSON Schema for a single fixed-grant row on a `RewardTable`. |
| `content/schemas/reward-table.schema.json` | JSON Schema for a single `RewardTable` row (uses reward-grant-row). |
| `content/schemas/encounter-def.schema.json` | JSON Schema for a single `EncounterDef` row. |
| `content/reward-tables/prototype_reward_tables.json` | Prototype one-table catalog (`schemaVersion` + `rewardTables` array). |
| `content/encounters/prototype_encounters.json` | Prototype one-encounter catalog (`schemaVersion` + `encounters` array). |
| `docs/plans/NEO-100-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `scripts/validate_content.py` | Load/validate encounter + reward-table catalogs; duplicate `id`; E5M3 one-id gates; NPC instance + item + rewardTableId cross-refs; success summary. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | CI enforcement note under freeze table; link to plan when complete. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for encounter + reward-table catalog validation. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 / NEO-100 status after catalog lands. |
## Tests
| Item | Coverage |
|------|----------|
| **CI / PR gate** | `.github/workflows/pr-gate.yml` runs `python scripts/validate_content.py` — extended script is the primary regression signal. |
| **Manual negative cases** | From repo root after implementation: (1) duplicate encounter or reward-table `id` → non-zero exit; (2) remove frozen id → gate error; (3) unknown `itemId` in `fixedGrants` → cross-ref error; (4) wrong NPC instance id set → cross-ref error; (5) broken `rewardTableId` on encounter → cross-ref error; (6) empty `displayName` or invalid `completionCriteria.kind` → schema error. |
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports encounter + reward-table catalogs + existing catalogs OK. |
No dedicated pytest module in-repo today (same as NEO-87 / NEO-76).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| NPC instance ids are server-registry constants, not content rows | **`PROTOTYPE_E5M2_NPC_INSTANCE_IDS` frozenset in CI** — mirror server `PrototypeNpcRegistry`; document sync obligation in module doc (same pattern as behavior ids without a content file). | **adopted** |
| `content/README.md` pre-documents NEO-100 before catalogs land | **Keep paragraph; verify constants match after ship** — decomposition already merged. | **adopted** |
| Cursor markdown `#anchor` deep links | Unrelated to this story; GitHub + stable ids remain source of truth. | **deferred** |

View File

@ -44,7 +44,7 @@
## 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] Hook names match [epic_05 Slice 1](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-1) 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

View File

@ -48,7 +48,7 @@ No other blocking decisions — combat loop HUD, hotbar pulse bootstrap, and eve
- [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.
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums.
## Implementation reconciliation (shipped)

View File

@ -46,7 +46,7 @@
## Acceptance criteria checklist
- [x] **`TODO(E9.M1)`** markers present at **`telegraph_fired`** and **`npc_state_transition`** hook sites in NPC runtime path.
- [x] Hook names match [epic_05 Slice 2](../decomposition/epics/epic_05_pve_combat.md#slice-2---npc-archetypes-and-telegraphs) vocabulary (`telegraph_fired`, `npc_state_transition`).
- [x] Hook names match [epic_05 Slice 2](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-2) vocabulary (`telegraph_fired`, `npc_state_transition`).
- [x] No runtime behavior change beyond comments / doc cross-links.
## Implementation reconciliation (shipped)

View File

@ -0,0 +1,181 @@
# NEO-98 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-98 |
| **Title** | E5M2-12: Playable NPC telegraph combat capstone (Godot) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-98/e5m2-12-playable-npc-telegraph-combat-capstone-godot |
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-12** |
| **Branch** | `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` |
| **Server deps** | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro + leash (**Done**); [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) runtime state machine (**Done**); [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET (**Done**); [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP (**Done**) |
| **Client deps** | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) telegraph HUD + archetype markers (**Done**); E5.M1 cast + combat-target HUD ([NEO-85](https://linear.app/neon-sprawl/issue/NEO-85), [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)) (**Done**) |
| **Pattern** | Capstone integration — [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) docs + manual QA primary; minimal client gap-fill only if capstone QA fails on `main` |
| **Server counterpart** | NEO-92NEO-95 server spine; 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 |
|--------|----------|----------------------|--------|
| **Implementation scope** | Docs-only vs client code? | **Docs + manual QA primary**; fix integration gaps only if capstone QA fails on `main` — [NEO-86](NEO-86-implementation-plan.md) precedent. | **User:** docs-primary; client fixes only if QA fails. |
| **Session baseline** | Fresh server vs fixture POST? | **Fresh server restart** before Godot **F5** — resets NPC HP, aggro/runtime rows, and player combat HP (NEO-86 / NEO-97 precedent); zero Bruno/curl in main checklist. | **User:** fresh restart; no Bruno/curl in checklist. |
| **Archetype order** | Order in single-session script? | **Melee → Ranged → Elite** — matches E5M2-12 backlog goal prose (“aggro a melee NPC… defeat all three archetypes”). | **User:** melee → ranged → elite. |
| **Verification depth** | Full telegraph spine per archetype? | **Full telegraph + player HP on Melee**; Ranged + Elite **defeat + telegraph visible** with documented windup/cooldown — keeps one-session script tractable while satisfying Slice 2 AC. | **User:** melee full; ranged/elite light. |
| **NEO-97 relationship** | Supersede component QA? | **NEO-98 supersedes as capstone**; keep [NEO-97](../manual-qa/NEO-97.md) as component-level regression reference (NEO-85 / NEO-86 pattern). | **Adopted** (repo precedent; not asked). |
| **Capstone QA scope expansion** | Server/catalog changes allowed? | Kickoff was docs-primary; capstone QA may drive minimal integration fixes — document any catalog or server touch in plan reconciliation. | **Adopted:** defeat-stop, **`prototype_npc_abilities.json`**, behavior **`attackAbilityId`**, range-gated telegraph resolve (see **Implementation reconciliation**). |
## Goal, scope, and out-of-scope
**Goal:** Prove Epic 5 Slice 2 acceptance **in Godot**: aggro a melee NPC, observe readable telegraph timing before damage, take incoming NPC damage, defeat all three archetypes — without Bruno.
**In scope (from Linear + [E5M2-12](E5M2-prototype-backlog.md#e5m2-12--playable-npc-telegraph-combat-capstone-godot)):**
- **`docs/manual-qa/NEO-98.md`**: numbered **single-session** capstone script — fresh dev player, **Melee → Ranged → Elite**, zero Bruno/curl steps in the main checklist.
- **`client/README.md`**: **End-to-end NPC telegraph combat loop** section — integration checklist (boot → lock → cast → telegraph → player HP → defeat ×3); cross-links NEO-92NEO-97.
- **Module alignment** (on story completion): update [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M2 row + E5M2-12 backlog checkboxes; note Epic 5 Slice 2 client capstone complete.
- **Integration fixes only if capstone QA fails** on current `main` wiring (unexpected deny, HUD refresh gap, poll gate, etc.).
**Out of scope (from Linear + backlog):**
- Encounters/loot ([E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)), spawn ecology ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
- Final VFX art, dodge mechanics, player death flow.
- Bruno-only verification as prototype-complete proof.
- New server HTTP routes beyond existing cast/snapshot surfaces; new NPC archetypes beyond the frozen three.
**Originally out of scope at kickoff:** behavior/ability catalog changes and server integration beyond docs — **expanded during capstone QA** (see kickoff **Capstone QA scope expansion** and **Implementation reconciliation**).
## Acceptance criteria checklist
- [x] Human completes **`docs/manual-qa/NEO-98.md`** with server + client; telegraph timing readable before damage on the melee spine.
- [x] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA.
- [x] All three archetypes defeated in one session; **`CombatTargetHpLabel`** shows defeated for each.
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums.
## Implementation reconciliation (shipped)
- **`docs/manual-qa/NEO-98.md`** — single-session capstone (Melee full telegraph spine → Ranged/Elite defeat + telegraph visible); zero Bruno/curl in main checklist.
- **`client/README.md`** — **End-to-end NPC telegraph combat loop (NEO-98)** section with flow table and cross-links.
- **`docs/plans/E5M2-prototype-backlog.md`**, **`E5_M2_NpcAiAndBehaviorProfiles.md`**, **`documentation_and_implementation_alignment.md`** — E5M2-12 landed; E5.M2 **Ready**; Epic 5 Slice 2 client capstone complete.
- **Integration fix (capstone QA):** defeated NPCs continued attacking — **`NpcRuntimeOperations.TryStopOnTargetDefeat`** on cast accept + **`AdvanceOne`** defeated guard; server tests added.
- **Integration fix (capstone QA):** telegraph damage at aggro radius — **`prototype_npc_abilities.json`** (three NPC attack abilities), behavior **`attackAbilityId`**, **`AbilityDef.maxRange`** on resolve; melee strike **2 m**, ranged **10 m**, elite **4 m**; CI cross-ref **`attackDamage`** ↔ ability **`baseDamage`**.
## Technical approach
### 1. Capstone manual QA script (`docs/manual-qa/NEO-98.md`)
**Preconditions:** Stop any running server; start fresh (`dotnet run`) so in-memory **`dev-local-1`** NPC HP, aggro/runtime rows, and player combat HP reset. Godot **F5** with **no** prior curl/Bruno seeding (hotbar **`prototype_pulse`** bind is automatic — NEO-85 dev bootstrap).
**Frozen catalog math** (from [E5_M2 freeze box](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01); player casts **`prototype_pulse`** **25** dmg, ~**3 s** cooldown):
| Archetype | Instance id | Marker | maxHp | Casts to defeat | attackDamage | strike range | windup | cooldown |
|-----------|-------------|--------|-------|-----------------|--------------|--------------|--------|----------|
| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | **2 m** | 1.5 s | 3.0 s |
| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 10 m | 2.0 s | 4.0 s |
| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | **4 m** | 2.5 s | 5.0 s |
**Melee full spine (telegraph + player HP):**
1. Walk to **Melee** marker; **Tab** lock **`prototype_npc_melee`** — **`Validity: ok`**.
2. Press **1** (damaging cast): **`NpcStateLabel`** → **`aggro`** (~1 Hz poll); **`PlayerCombatHpLabel`** → **`100/100`**.
3. Wait **≥ 3 s** from first cast (cooldown elapses): **`NpcStateLabel`** → **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (**`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls.
4. Wait **≥ 4.5 s** total from first cast (windup completes): stay **within ~2 m** of the melee marker; **`PlayerCombatHpLabel`** → **`85/100`** (15 damage). Back off during windup → whiff (no damage).
5. Press **1** three more times (respect cooldown) until **`CombatTargetHpLabel`** → **`0/100 (defeated)`** and cast feedback shows defeat.
**Ranged light (defeat + telegraph visible):**
6. Walk to **Ranged** marker; lock **`prototype_npc_ranged`**.
7. First damaging cast → aggro; wait for **`telegraph_windup`** + readable **`TelegraphLabel`** (2.0 s windup after 4.0 s cooldown from first cast).
8. Cast until defeated (**4** pulses); verify deny on re-cast (**`target_defeated`**).
**Elite light (defeat + telegraph visible):**
9. Walk to **Elite** marker; lock **`prototype_npc_elite`**.
10. First damaging cast → aggro; wait for **`telegraph_windup`** + readable **`TelegraphLabel`** (2.5 s windup after 5.0 s cooldown).
11. Cast until defeated (**8** pulses); verify deny on re-cast.
**Regression spot-checks (end of session):**
- Cast without lock → **`ability_cast_denied: invalid_target`** (NEO-28).
- **`CombatTargetHpLabel`** + cooldown HUD still work (NEO-85 / NEO-32).
- Optional deeper telegraph/HP checks: [NEO-97 manual QA](../manual-qa/NEO-97.md) (elite incoming-threat row).
**Notes section (not main checklist steps):**
- Optional mid-session reset (Development host): `POST /game/__dev/combat-targets-fixture` — see [NEO-97](../manual-qa/NEO-97.md) / [server README — dev fixture](../../../server/README.md#dev-combat-target-fixture-brunomanual-qa).
- Capstone baseline prefers **server restart** over fixture POST.
### 2. README integration checklist
New **`## End-to-end NPC telegraph combat loop (NEO-98)`** section after [NEO-97 NPC runtime + telegraph HUD](NEO-97-implementation-plan.md) subsection in `client/README.md`:
- Flow in prose: fresh server → boot hotbar sync → **Melee** lock → cast → telegraph HUD → player HP drop → defeat → repeat **Ranged** + **Elite**.
- Table mirroring capstone steps (lock, telegraph labels, defeat cast counts).
- Cross-links: NEO-92 (aggro), NEO-94 (runtime snapshot), NEO-95 (player combat HP), NEO-97 (HUD poll clients).
- Pointer to **`docs/manual-qa/NEO-98.md`** as authoritative capstone script.
- Note NEO-97 manual QA as component-level regression.
### 3. Module alignment (implementation batch or story end)
When acceptance criteria pass:
- **`documentation_and_implementation_alignment.md`**: E5.M2 — note **NEO-98 landed**, Epic 5 Slice 2 client capstone complete.
- **`E5M2-prototype-backlog.md`**: E5M2-12 acceptance checkboxes + landed note.
- **`E5_M2_NpcAiAndBehaviorProfiles.md`**: E5M2-12 row + capstone cross-link if not already marked.
### 4. Integration fixes (conditional)
Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix minimal client bug in this branch and note in plan **Decisions**. Expected path: docs + README only (NEO-97 HUD wiring landed in PR #136).
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-98-implementation-plan.md` | This plan. |
| `docs/manual-qa/NEO-98.md` | Capstone single-session manual QA (zero Bruno/curl in main checklist). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/README.md` | End-to-end NPC telegraph combat loop section; capstone manual QA link; cross-links NEO-92NEO-97. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — NEO-98 landed + Slice 2 client capstone complete (on story completion). |
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-12 acceptance checkboxes + landed note (on story completion). |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | E5M2-12 capstone landed note + manual QA link (on story completion). |
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs` | **`TryStopOnTargetDefeat`** + defeated guard in **`AdvanceOne`**; **`AdvanceAll`** takes **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`**. |
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | Pass ability registry + combat health store into **`AdvanceAll`**. |
| `server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs` | Range-gated resolve via behavior **`attackAbilityId`** + ability **`maxRange`**. |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Stop NPC combat on **`TargetDefeated`**. |
| `content/abilities/prototype_npc_abilities.json` | Three NPC attack abilities + strike **`maxRange`**. |
| `content/npc-behaviors/prototype_npc_behaviors.json` | **`attackAbilityId`** per archetype. |
| `content/schemas/ability-def.schema.json`, `npc-behavior-def.schema.json` | **`maxRange`**, **`attackAbilityId`**. |
| `scripts/validate_content.py` | Seven-id ability gate + behavior ↔ ability cross-ref. |
| `server/README.md` | Threat table — **Attack range**, **Defeat clear**, **`attackAbilityId`** resolve path. |
**Conditional (only if capstone QA fails on client):**
| Path | Rationale |
|------|-----------|
| `client/scripts/main.gd` | Minimal HUD / poll / sync fix for failing capstone step. |
| Other client scripts under `client/scripts/` | Only if root cause is outside `main.gd` (document in plan **Decisions**). |
## Tests
| Path | Change |
|------|--------|
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs` | **`TryStopOnTargetDefeat`** + defeated **`AdvanceAll`** guard tests. |
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | **`PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated`** integration test. |
**If integration fix adds/changes client behavior:** add or extend the smallest GdUnit test in the matching NEO-97 suite (`npc_combat_hud_refresh_test.gd`, etc.) — AAA layout per [testing-expectations.md](../../.cursor/rules/testing-expectations.md).
**Manual verification:** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — server + Godot; melee telegraph spine + three-archetype defeat without curl.
## Open questions / risks
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| **Capstone dry-run on `main`** | Capstone QA found defeated melee still attacking; server fix clears aggro + runtime on defeat. | **adopted** |
| **Session length (elite ×8 casts)** | Document cast counts + cooldown reminders; elite section is defeat + telegraph visibility only (kickoff depth decision). | **adopted** |
| **Player HP drift across three fights** | Script asserts melee **85/100** only; later archetype hits optional in Notes — do not require exact final HP in AC. | **adopted** |
| **Overlap with NEO-97 manual QA** | NEO-98 supersedes as capstone; NEO-97 stays component regression (elite incoming-threat row). | **adopted** |
| **NEO-97 merge dependency** | **NEO-97 merged** to `main` (PR #136) — capstone can proceed. | **adopted** |

View File

@ -0,0 +1,60 @@
# Code review — NEO-100 (E5M3-01)
**Date:** 2026-05-30
**Scope:** Branch `NEO-100-e5m3-encounter-reward-catalog-schemas-ci` vs `cb86645` (merge-base on `main`) — commits `4f16c84``ee5e1a0`
**Base:** `cb86645` (main after PR #138)
## Verdict
**Approve** — review follow-up applied (2026-05-30).
## Summary
NEO-100 delivers the **E5M3-01** content spine: three JSON Schemas (`encounter-def`, `reward-table`, `reward-grant-row`), prototype catalog files with the frozen **`prototype_combat_pocket`** encounter and **`prototype_combat_pocket_clear`** reward table, and **`scripts/validate_content.py`** extensions mirroring the NEO-87 / NEO-76 catalog pattern (schema validation, duplicate `id` rejection, prototype id allowlists, item / NPC instance / reward-table cross-refs, reward-table-before-encounter ordering). Documentation updates match the shipped artifacts. Review follow-ups (dependency register sync, duplicate `fixedGrants` rejection, `uniqueItems` on NPC ids, plan audit note) are **Done.**
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-100-implementation-plan.md` | **Matches** — scope, constants, file list, acceptance checklist, and implementation reconciliation align with the diff. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-01) | **Matches** — three AC items checked; landed note cites schemas, catalogs, and CI gates. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — CI enforcement paragraph under freeze table; ids and cross-ref rules match `validate_content.py` constants. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — reward-table and encounter catalog paragraphs added with correct schema paths and gate semantics. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-100 catalog landed**; server load still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-100 catalog landed**; server load still outstanding. |
| `content/README.md` | **Matches** — NEO-100 paragraph and table rows for `encounters/` / `reward-tables/` match shipped ids and cross-ref rules (plan “verify only”). |
| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until E5M3-08+ / NEO-110 / NEO-111; no player-visible claim in this story. |
| `server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs` | **Matches** (cross-ref) — CI `PROTOTYPE_E5M2_NPC_INSTANCE_IDS` matches server constants (`prototype_npc_melee`, `prototype_npc_ranged`, `prototype_npc_elite`). |
## Blocking issues
None.
## Suggestions
1. ~~**Update `module_dependency_register.md` E5.M3 note** — Replace “implementation not started” with **E5M3-01 / NEO-100 catalog landed** (schemas + CI); server load/runtime still NEO-101+. Keeps register consistent with `documentation_and_implementation_alignment.md` and E5.M3 module doc.~~ **Done.**
2. ~~**Duplicate `fixedGrants` rows** — `_prototype_e5m3_reward_grant_content_gate` folds grants into a dict (last row wins). Duplicate `itemId` rows that merge to the frozen quantities **pass CI** (verified locally). For NEO-101 loader clarity, consider rejecting duplicate `itemId` in `fixedGrants` (schema `uniqueItems` on a composite key is awkward; a small loop in the grant gate matches mastery `perkId` duplicate rejection). Same pattern gap exists on recipe I/O rows — optional to defer until roster expansion.~~ **Done.** `_validate_reward_table_catalogs` rejects duplicate **`itemId`** per table; E5.M3 + CT.M1 docs updated. Recipe I/O duplicate deferral unchanged.
## Nits
- ~~Nit: **`requiredNpcInstanceIds`** schema omits `uniqueItems: true`; the set-equality gate catches wrong sets for the one-row prototype, but adding `uniqueItems` would fail fast on duplicate ids before the custom gate (and documents intent for NEO-101).~~ **Done.**
- ~~Nit: Plan item 7 lists **`content/README.md` verify** — reconciliation section could explicitly note “verified, no edit required” for audit trail (optional).~~ **Done.**
## Verification
```bash
# Primary regression (also runs in PR gate)
python3 scripts/validate_content.py
# Manual negative spot-checks (from plan)
# 1. Duplicate encounter or reward-table id → non-zero exit
# 2. Remove frozen id from catalog → gate error
# 3. Unknown itemId in fixedGrants → cross-ref error
# 4. Wrong requiredNpcInstanceIds set → cross-ref error
# 5. Broken rewardTableId → cross-ref error
# 6. Invalid completionCriteria.kind or empty displayName → schema error
```
**Before merge:** Confirm PR gate green on the branch (`validate_content.py` is the sole automated test surface for this story).

View File

@ -0,0 +1,67 @@
# Code review — NEO-98 follow-up (capstone QA integration fixes)
**Date:** 2026-05-30
**Scope:** Branch `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` vs `origin/main` — commits `b03128a``44fb4ad` (delta since [2026-05-30-NEO-98.md](2026-05-30-NEO-98.md) at `817ed26`)
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
Since the first review, NEO-98 expanded beyond docs-only into **capstone-driven server integration**: defeated NPCs no longer keep attacking (`TryStopOnTargetDefeat` on cast accept + defeated guard in `AdvanceOne`), and telegraph damage is **range-gated** via new NPC attack abilities (`content/abilities/prototype_npc_abilities.json`, behavior **`attackAbilityId`**, `NpcAttackOperations` horizontal **`maxRange`** check). Content/schema/CI gates cross-reference behavior **`attackDamage`** against ability **`baseDamage`**. Manual QA and README document melee **2 m** strike range and the whiff case. Follow-up doc suggestions (E5.M2 module freeze, plan scope reconciliation, alignment register, client README melee row, plan timing split) are addressed in a docs batch after this review.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-98-implementation-plan.md` | **Matches** — kickoff **Capstone QA scope expansion**; revised **Out of scope**; reconciliation + files list cover defeat-stop and attack-ability range binding; melee spine splits **≥ 3 s** telegraph / **≥ 4.5 s** damage. |
| `docs/manual-qa/NEO-98.md` | **Matches** — strike-range column, step 8 **2 m** proximity + whiff note, Esc regression (steps 1921), full **`CombatTargetHpLabel`** format on step 4. |
| `client/README.md` | **Matches** — flow table uses full target HP label; **Melee hit** row documents **within ~2 m** + whiff. |
| `docs/plans/E5M2-prototype-backlog.md` (E5M2-12) | **Matches** — fourth AC (three-archetype defeat) present; landed note intact. |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — freeze table includes **`attackAbilityId`** + NPC attack ability strike ranges; NEO-95/NEO-98 bullets document range-gated resolve and defeat-clear. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 **Ready** + NEO-98 capstone + server integration clause. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M2 note unchanged from docs batch; still cites NEO-98 capstone manual QA. |
| `server/README.md` | **Matches** — threat table documents **Attack range**, **Defeat clear**, and **`attackAbilityId`** resolve path. |
| `docs/manual-qa/NEO-97.md` | **Matches** — component regression still valid (elite at origin within **4 m** slam); no update required for capstone range work. |
| Full-stack epic decomposition | **Matches** — Godot capstone manual QA remains the prototype-complete proof; server fixes support playable QA without Bruno. |
## Blocking issues
None.
## Suggestions
1. ~~**E5.M2 module doc — NEO-95 / NEO-98 server behavior** — Update the **Session player combat HP (NEO-95)** bullet and prototype freeze table to document **`attackAbilityId`**, NPC attack ability ids, and strike **`maxRange`** values (**2 / 10 / 4 m**). Note **`attackDamage`** on behavior rows is a projection/cross-ref field, not the resolve source after NEO-98.~~ **Done.** [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) freeze table + NPC attack ability table; NEO-95/NEO-98 bullets updated.
2. ~~**Plan scope reconciliation** — Revise **Out of scope** (or add a **Decisions** row) to record capstone QA drove catalog + server changes: `prototype_npc_abilities.json`, behavior **`attackAbilityId`**, defeat-stop, range-gated resolve. Keeps kickoff “docs-primary” honest while documenting the adopted integration path.~~ **Done.** Kickoff **Capstone QA scope expansion** row + revised **Out of scope** / reconciliation in [`NEO-98-implementation-plan.md`](../plans/NEO-98-implementation-plan.md).
3. ~~**`documentation_and_implementation_alignment.md` E5.M2 row** — Append a short **NEO-98 server integration** clause (defeat clears aggro/runtime; telegraph damage uses attack ability **`maxRange`**) alongside the existing capstone manual QA note.~~ **Done.** NEO-95 + NEO-98 clauses updated in alignment register.
4. ~~**`client/README.md` melee hit row** — Mirror manual QA step 8: tester must stay **within ~2 m** of the melee marker for **85/100**; backing off during windup whiffs.~~ **Done.** Flow table **Melee hit** row updated.
## Nits
- ~~Nit: Plan §1 melee spine prose still collapses telegraph + HP into a single **≥ 4.5 s** wait; manual QA correctly splits **≥ 3 s** (telegraph) and **≥ 4.5 s** (damage) — tighten plan technical approach for consistency.~~ **Done.** Plan melee spine steps 34 split timing + **2 m** whiff note.
- Nit: `AdvanceAll` now requires **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`** — any future caller besides `NpcRuntimeSnapshotWorldApi` must pass the expanded signature (only one production caller today).
- Nit: Whiff path advances NPC state to **`recover`** without damage — correct per server README; optional manual QA note for Ranged/Elite if testers back off during windup (not required for capstone AC).
## Verification
```bash
# NEO-98 server spine (in-memory; no Docker)
cd server/NeonSprawl.Server.Tests && dotnet test \
--filter "FullyQualifiedName~NpcRuntime|FullyQualifiedName~NpcAttack|FullyQualifiedName~PostAbilityCast_ShouldStopNpcCombat"
# Content cross-ref gates
python3 scripts/validate_content.py
```
**Local run:** 41/41 filtered tests passed; 500/511 full suite passed (11 Postgres/docker integration failures — environment infra, unrelated to this diff).
**Manual (required before merge):** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — confirm step 8 (**85/100** only when within **2 m**), defeated melee does **not** keep damaging player after step 9, three-archetype defeat, Esc + NEO-28 deny (steps 1920).
**Optional:** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) incoming-threat row regression.

View File

@ -0,0 +1,56 @@
# Code review — NEO-98 (E5M2-12)
**Date:** 2026-05-30
**Scope:** Branch `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` vs `origin/main` — commits `ceef54c``ae8b01d`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-98 closes **E5M2-12** and Epic 5 Slice 2 client acceptance with a **docs-only** capstone: `docs/manual-qa/NEO-98.md` (Melee full telegraph spine → Ranged/Elite defeat + telegraph visibility), `client/README.md` end-to-end loop section, implementation plan with kickoff decisions, and module/backlog/alignment/register updates marking **E5.M2 Ready**. No client or server code changes — consistent with kickoff (“docs + manual QA primary; fix gaps only if QA fails on `main`”) and server test evidence cited in the plan (`NpcRuntimeOperationsTests`, `NpcRuntimeSnapshotWorldApiTests` at 4.5 s / **85** HP). Residual risk is low and operational: a long single-session script (~20 steps, elite ×8 casts) and reliance on human Godot verification before merge; timing math in the checklist matches frozen catalog values and server tests.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-98-implementation-plan.md` | **Matches** — kickoff decisions (docs-primary, fresh restart, Melee→Ranged→Elite, melee full / ranged+elite light, NEO-97 as component regression), scope, file list, and reconciliation align with the diff; no integration fixes shipped. |
| `docs/plans/E5M2-prototype-backlog.md` (E5M2-12) | **Matches** — landed note and four AC items checked (includes three-archetype defeat). |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — Status **Ready** with E5M2-11 + E5M2-12 landed; capstone cross-link + manual QA pointer added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row **Ready**, **NEO-98 landed**, Slice 2 client capstone complete. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M2 note summarizes NEO-92NEO-98 and marks Slice 2 complete. |
| `docs/manual-qa/NEO-98.md` | **Matches** — single-session Godot script, archetype table, melee defeat math, zero Bruno/curl in main checklist; acceptance boxes intentionally unchecked pre-human QA (NEO-86 precedent). |
| `client/README.md` | **Matches****End-to-end NPC telegraph combat loop (NEO-98)** section after NEO-97; flow table, cross-links, script list, capstone manual QA link. |
| `docs/decomposition/epics/epic_05_pve_combat.md` (Slice 2) | **Matches** — backlog pointer to NEO-98 + Godot verification; no epic doc edit required for this story. |
| Full-stack epic decomposition | **Matches** — client capstone **NEO-98** with Godot manual QA; not Bruno-only prototype proof. |
## Blocking issues
None.
## Suggestions
1. ~~**Regression step — clear lock before `invalid_target` check** — After step 18 the tester is still locked on defeated **`prototype_npc_elite`**; step 19 (“cast without lock”) will hit **`target_defeated`**, not **`invalid_target`**, unless lock is cleared first. Add an **Esc** step (mirror [NEO-86 manual QA](../manual-qa/NEO-86.md) steps 810) before the NEO-28 regression assertion.~~ **Done.** Manual QA step 19 **Esc** clear; NEO-28 check is step 20.
2. ~~**E5M2-12 backlog AC parity** — [NEO-98 implementation plan](../plans/NEO-98-implementation-plan.md) lists a fourth AC (“All three archetypes defeated… **`CombatTargetHpLabel`** shows defeated for each”); [E5M2-prototype-backlog.md](../plans/E5M2-prototype-backlog.md) E5M2-12 section omits it. Add the checkbox for traceability with the plan and manual QA steps 9/14/18.~~ **Done.**
## Nits
- ~~Nit: **E5.M2 Summary `Linear` row** still abbreviates **`E5M2-05 … → E5M2-12`** while the **Status** row now lists E5M2-06E5M2-11 inline — optional consistency pass.~~ **Done.** Linear row lists E5M2-01E5M2-12 inline.
- ~~Nit: Manual QA / README use shorthand **`100/100`** on **`CombatTargetHpLabel`**; NEO-85 format includes **`Target HP: {id}`** prefix — testers should match substring, not exact label text (or tighten one example step to full format).~~ **Done.** Preconditions note + step 4 full format; README flow table updated.
- Nit: Plan / backlog AC boxes are **`[x]`** while **`docs/manual-qa/NEO-98.md` Acceptance** stays **`[ ]`** until a human run — intentional per NEO-86; keep that distinction when pasting PR text.
## Verification
```bash
# Server timing spine (no client required — confirms melee telegraph math cited in plan)
cd server/NeonSprawl.Server.Tests && dotnet test --filter "FullyQualifiedName~NpcRuntime"
```
**Manual (required before merge):** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — fresh server restart, Godot **F5**, Melee steps 310 (telegraph visible **before** **85/100**), Ranged + Elite defeat with telegraph visible, regression steps 1921 (**Esc** before NEO-28 deny).
**Optional component regression:** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) — elite incoming-threat row when locking a different NPC while elite holds aggro.

View File

@ -0,0 +1,77 @@
# Documentation review — Epic 5 anchor stability + doc cluster
**Date:** 2026-05-30
**Scope:** Uncommitted docs batch — `epic_05_pve_combat.md` stable slice anchors; link updates in `E5M1`/`E5M2`/`E5M3` backlogs, `NEO-84`/`NEO-86`/`NEO-96`/`NEO-98` plans, `server/README.md`; cross-check against Epic 5 modules, game-design progression seams, and full-stack backlog policy
**Base:** Working tree on `main` (uncommitted)
## Verdict
**Approve** — suggestions applied (2026-05-30 follow-up).
## Summary
The batch correctly adds explicit HTML slice anchors to `epic_05_pve_combat.md` (matching the Epic 1 / Epic 3 precedent) and removes fragile GitHub heading slugs (`#slice-1---combat-rules-mvp`, etc.) that were prone to renderer drift. Hook vocabulary, gig-vs-skill XP boundaries, and full-stack E5M1/E5M2/E5M3 backlog pairing remain aligned with game-design and module docs. Follow-ups from initial review (stable deep links, E5M2 NEO-98 resolve vocabulary, E5.M3 Linear row) are **Done.**
## Documentation checked
| Path | Result |
|------|--------|
| `docs/decomposition/epics/epic_05_pve_combat.md` | **Matches**`<a id="epic-5-slice-1">``epic-5-slice-4`, `epic-5-definition-of-done`; slice telemetry vocabulary unchanged. |
| `docs/plans/E5M1-prototype-backlog.md` | **Matches** — epic links use stable `#epic-5-slice-1` / `#epic-5-definition-of-done` anchors; Slice 1 complete; full-stack checklist satisfied. |
| `docs/plans/E5M2-prototype-backlog.md` | **Matches** — stable slice/DoD anchors; kickoff + E5M2-09 document NEO-98 **`attackAbilityId`** resolve supersession; full-stack pairing **NEO-97**/**NEO-98** intact. |
| `docs/plans/E5M3-prototype-backlog.md` | **Matches** — stable `#epic-5-slice-3` / DoD anchors; decomposition checklist + client capstone **NEO-111** present. Gig XP / encounter loot separation matches `progression.md` seams. |
| `docs/plans/NEO-84-implementation-plan.md` | **Matches** — hook names align with epic Slice 1 list. |
| `docs/plans/NEO-86-implementation-plan.md` | **Matches** — capstone AC + gig XP proof; DoD link uses `#epic-5-definition-of-done`. |
| `docs/plans/NEO-96-implementation-plan.md` | **Matches** — Slice 2 hook vocabulary; reconciliation complete. |
| `docs/plans/NEO-98-implementation-plan.md` | **Matches** — capstone scope reconciliation documents NEO-98 server integration; aligns with [2026-05-30-NEO-98-followup.md](2026-05-30-NEO-98-followup.md). |
| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | **Matches** — gig XP on defeat via NEO-44; combat → gig, not E2.M2 skill XP. |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — freeze table + NEO-98 `attackAbilityId` / range-gated resolve documented. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — slug table (lines 5063) and summary **Linear** row both use **E5M3-08 → NEO-108**, **E5M3-09 → NEO-107**. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M1 Ready, E5.M2 Ready + NEO-98 integration, E5.M3 decomposed In Progress. |
| `docs/game-design/progression.md`, `gigs.md`, `abilities.md` | **N/A** (not edited) — spot-check: combat encounters → gig XP; no skill XP from fight loop reintroduced in Epic 5 docs. |
| `server/README.md` (NEO-84 combat hooks) | **Matches** — epic link valid; hook semantics unchanged. |
| `server/README.md` (NEO-96 NPC hooks) | **Matches** — epic cross-link to Slice 2 anchor added (parity with NEO-84). |
## Blocking issues
None.
## Suggestions
1. ~~**Use stable slice anchors in deep links** — Anchors exist (`epic-5-slice-1` … `epic-5-slice-4`, `epic-5-definition-of-done`) but updated links drop fragments entirely. Match Epic 3 / E3M1 pattern:~~ **Done.** Backlog intros, telemetry AC, DoD re-read AC, and `server/README.md` NEO-84/NEO-96 now use `#epic-5-slice-N` / `#epic-5-definition-of-done`.
~~Example target:~~
```markdown
[Epic 5 · Slice 2 — NPC archetypes and telegraphs](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-2)
```
2. ~~**E5M2 backlog — NEO-98 resolve source drift** — Kickoff table row **NPC attacks** and **E5M2-09** goal still say telegraph resolve applies `NpcBehaviorDef.attackDamage`. Module doc and NEO-98 plan correctly document **`attackAbilityId`** + ability **`baseDamage`** / **`maxRange`**. Add a kickoff footnote or revise the E5M2-09 goal + landed **NEO-95** line to note NEO-98 supersession (behavior **`attackDamage`** = projection/CI cross-ref only).~~ **Done.**
3. ~~**E5_M3 module summary Linear row** — Line 11 lists **E5M3-08 → NEO-107**, **E5M3-09 → NEO-108**; backlog and slug table in the same module file use **E5M3-08 → NEO-108**, **E5M3-09 → NEO-107** (documented creation-order swap). Fix the summary **Linear** cell to match the slug table.~~ **Done.**
## Nits
- ~~**Related docs path style** — `E5M2`/`E5M3` backlogs use `./../decomposition/epics/epic_05_pve_combat.md`; `E5M1` uses `../decomposition/...`. Normalize to one form.~~ **Done.** All E5 backlogs use `../decomposition/...`.
- **Epic 5 Slice 4** — Anchor `epic-5-slice-4` added with no current consumers (pre-production); fine as forward-looking stability.
- **Prior reviews** — [2026-05-30-NEO-98-followup.md](2026-05-30-NEO-98-followup.md) suggestions are addressed; no reopen needed for NEO-98 doc cluster.
## Verification
After applying suggestions:
```bash
# No remaining fragile epic_05 heading slugs
rg 'epic_05_pve_combat\.md#' docs/ server/
# Epic 5 stable anchors referenced where slice-specific
rg 'epic-5-slice-|epic-5-definition-of-done' docs/
# E5M3 slug ↔ Linear consistency
rg 'E5M3-0[89].*NEO-10[78]' docs/plans/E5M3-prototype-backlog.md docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md
# NEO-98 resolve vocabulary in E5M2 backlog
rg 'attackDamage|attackAbilityId' docs/plans/E5M2-prototype-backlog.md
```
Human re-read: epic Slice 13 telemetry hook lists vs plan AC checklists; E5M3 kickoff **Gig XP** row vs `progression.md` combat → gig seam.

View File

@ -12,6 +12,8 @@ Validates:
- 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)
- reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100)
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
"""
from __future__ import annotations
@ -34,6 +36,9 @@ 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"
REWARD_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reward-grant-row.schema.json"
REWARD_TABLE_SCHEMA = REPO_ROOT / "content/schemas/reward-table.schema.json"
ENCOUNTER_DEF_SCHEMA = REPO_ROOT / "content/schemas/encounter-def.schema.json"
SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items"
@ -41,6 +46,8 @@ 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"
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
@ -105,6 +112,17 @@ PROTOTYPE_E5M1_ABILITY_IDS = frozenset(
}
)
# Epic 5 Slice 2 NPC attack abilities (NEO-98): exact ids after schema passes.
PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS = frozenset(
{
"prototype_npc_melee_strike",
"prototype_npc_ranged_shot",
"prototype_npc_elite_slam",
}
)
PROTOTYPE_ABILITY_CATALOG_IDS = PROTOTYPE_E5M1_ABILITY_IDS | PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS
# 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(
@ -115,6 +133,25 @@ PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
}
)
# Epic 5 Slice 2 NPC instance ids (server PrototypeNpcRegistry); CI cross-ref only (NEO-100).
PROTOTYPE_E5M2_NPC_INSTANCE_IDS = frozenset(
{
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite",
}
)
# Epic 5 Slice 3 prototype lock (NEO-100): exact encounter + reward table ids after schema passes.
PROTOTYPE_E5M3_ENCOUNTER_IDS = frozenset({"prototype_combat_pocket"})
PROTOTYPE_E5M3_REWARD_TABLE_IDS = frozenset({"prototype_combat_pocket_clear"})
# Frozen fixed grants for prototype_combat_pocket_clear (keep in sync with E5.M3 freeze table).
PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
"scrap_metal_bulk": 10,
"contract_handoff_token": 1,
}
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."""
@ -428,10 +465,11 @@ def _validate_ability_catalogs(
*,
ability_files: list[Path],
ability_validator: Draft202012Validator,
) -> tuple[int, dict[str, str]]:
"""Validate ability JSON files. Returns (error_count, seen_ids)."""
) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate ability 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 ability_files:
rel = str(path.relative_to(REPO_ROOT))
@ -467,17 +505,18 @@ def _validate_ability_catalogs(
errors += 1
else:
seen_ids[aid] = rel
id_to_row[aid] = row
return errors, seen_ids
return errors, seen_ids, id_to_row
def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E5M1 ability contract fails, else None."""
def _prototype_ability_catalog_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if prototype ability catalog contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E5M1_ABILITY_IDS:
if ids != PROTOTYPE_ABILITY_CATALOG_IDS:
return (
"error: prototype E5M1 expects exactly ability ids "
f"{sorted(PROTOTYPE_E5M1_ABILITY_IDS)!r}, got {sorted(ids)!r}"
"error: prototype ability catalog expects exactly ability ids "
f"{sorted(PROTOTYPE_ABILITY_CATALOG_IDS)!r}, got {sorted(ids)!r}"
)
return None
@ -555,6 +594,235 @@ def _prototype_e5m2_npc_behavior_numeric_gate(id_to_row: dict[str, dict]) -> str
return None
def _prototype_e5m2_npc_behavior_attack_ability_gate(
id_to_row: dict[str, dict],
ability_id_to_row: dict[str, dict],
) -> str | None:
"""Return a human-readable error when attackAbilityId is missing or attackDamage mismatches."""
for bid, row in id_to_row.items():
aid = row.get("attackAbilityId")
if not isinstance(aid, str):
continue
ability = ability_id_to_row.get(aid)
if ability is None:
return (
f"error: npc behavior {bid!r}: attackAbilityId {aid!r} missing from ability catalog"
)
attack_damage = row.get("attackDamage")
base_damage = ability.get("baseDamage")
if isinstance(attack_damage, int) and isinstance(base_damage, int) and attack_damage != base_damage:
return (
f"error: npc behavior {bid!r}: attackDamage {attack_damage} must match "
f"ability {aid!r} baseDamage {base_damage}"
)
return None
def _reward_table_def_validator() -> Draft202012Validator:
"""Build a validator that resolves reward-table $ref to reward-grant-row schema."""
grant_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
table_schema = json.loads(REWARD_TABLE_SCHEMA.read_text(encoding="utf-8"))
registry = Registry().with_resources(
[
(table_schema["$id"], Resource.from_contents(table_schema)),
(grant_schema["$id"], Resource.from_contents(grant_schema)),
]
)
return Draft202012Validator(table_schema, registry=registry)
def _validate_reward_table_catalogs(
*,
reward_table_files: list[Path],
reward_table_validator: Draft202012Validator,
known_item_ids: frozenset[str],
) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate reward table 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 reward_table_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
reward_tables = data.get("rewardTables")
if not isinstance(reward_tables, list):
print(f"error: {rel}: expected top-level 'rewardTables' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(reward_tables):
if not isinstance(row, dict):
print(f"error: {rel}: rewardTables[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(reward_table_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} rewardTables[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
rid = row.get("id")
if isinstance(rid, str) and row_schema_errors == 0:
prev = seen_ids.get(rid)
if prev:
print(f"error: duplicate reward table id {rid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[rid] = rel
id_to_row[rid] = row
fixed_grants = row.get("fixedGrants")
if isinstance(fixed_grants, list) and row_schema_errors == 0:
seen_grant_item_ids: set[str] = set()
for j, grant in enumerate(fixed_grants):
if not isinstance(grant, dict):
continue
item_id = grant.get("itemId")
if isinstance(item_id, str):
if item_id in seen_grant_item_ids:
print(
f"error: {rel} rewardTables[{i}].fixedGrants[{j}]: "
f"duplicate itemId {item_id!r}",
file=sys.stderr,
)
errors += 1
else:
seen_grant_item_ids.add(item_id)
if item_id not in known_item_ids:
print(
f"error: {rel} rewardTables[{i}].fixedGrants[{j}]: itemId {item_id!r} "
"is not in item catalogs",
file=sys.stderr,
)
errors += 1
return errors, seen_ids, id_to_row
def _prototype_e5m3_reward_table_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E5M3 reward table contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E5M3_REWARD_TABLE_IDS:
return (
"error: prototype E5M3 expects exactly reward table ids "
f"{sorted(PROTOTYPE_E5M3_REWARD_TABLE_IDS)!r}, got {sorted(ids)!r}"
)
return None
def _prototype_e5m3_reward_grant_content_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if frozen grant quantities fail, else None."""
row = id_to_row.get("prototype_combat_pocket_clear")
if row is None:
return None
fixed_grants = row.get("fixedGrants")
if not isinstance(fixed_grants, list):
return None
grants: dict[str, int] = {}
for grant in fixed_grants:
if not isinstance(grant, dict):
continue
item_id = grant.get("itemId")
quantity = grant.get("quantity")
if isinstance(item_id, str) and isinstance(quantity, int):
grants[item_id] = quantity
if grants != PROTOTYPE_E5M3_REWARD_GRANTS:
return (
"error: prototype_combat_pocket_clear fixedGrants must match "
f"{PROTOTYPE_E5M3_REWARD_GRANTS!r}, got {grants!r}"
)
return None
def _validate_encounter_catalogs(
*,
encounter_files: list[Path],
encounter_validator: Draft202012Validator,
known_reward_table_ids: frozenset[str],
) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate encounter 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 encounter_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
encounters = data.get("encounters")
if not isinstance(encounters, list):
print(f"error: {rel}: expected top-level 'encounters' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(encounters):
if not isinstance(row, dict):
print(f"error: {rel}: encounters[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(encounter_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} encounters[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
eid = row.get("id")
if isinstance(eid, str) and row_schema_errors == 0:
prev = seen_ids.get(eid)
if prev:
print(f"error: duplicate encounter id {eid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[eid] = rel
id_to_row[eid] = row
reward_table_id = row.get("rewardTableId")
if isinstance(reward_table_id, str) and reward_table_id not in known_reward_table_ids:
print(
f"error: {rel} encounters[{i}]: rewardTableId {reward_table_id!r} "
"is not in reward table catalogs",
file=sys.stderr,
)
errors += 1
return errors, seen_ids, id_to_row
def _prototype_e5m3_encounter_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E5M3 encounter contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E5M3_ENCOUNTER_IDS:
return (
"error: prototype E5M3 expects exactly encounter ids "
f"{sorted(PROTOTYPE_E5M3_ENCOUNTER_IDS)!r}, got {sorted(ids)!r}"
)
return None
def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if NPC instance or reward table cross-refs fail, else None."""
for eid, row in id_to_row.items():
npc_ids = row.get("requiredNpcInstanceIds")
if isinstance(npc_ids, list):
npc_set = frozenset(str(x) for x in npc_ids if isinstance(x, str))
if npc_set != PROTOTYPE_E5M2_NPC_INSTANCE_IDS:
return (
f"error: encounter {eid!r}: requiredNpcInstanceIds must be exactly "
f"{sorted(PROTOTYPE_E5M2_NPC_INSTANCE_IDS)!r}, got {sorted(npc_set)!r}"
)
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:
@ -881,6 +1149,15 @@ def main() -> int:
if not NPC_BEHAVIOR_SCHEMA.is_file():
print(f"error: missing schema {NPC_BEHAVIOR_SCHEMA}", file=sys.stderr)
return 1
if not REWARD_GRANT_ROW_SCHEMA.is_file():
print(f"error: missing schema {REWARD_GRANT_ROW_SCHEMA}", file=sys.stderr)
return 1
if not REWARD_TABLE_SCHEMA.is_file():
print(f"error: missing schema {REWARD_TABLE_SCHEMA}", file=sys.stderr)
return 1
if not ENCOUNTER_DEF_SCHEMA.is_file():
print(f"error: missing schema {ENCOUNTER_DEF_SCHEMA}", file=sys.stderr)
return 1
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema)
@ -899,6 +1176,9 @@ def main() -> int:
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)
reward_table_validator = _reward_table_def_validator()
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
encounter_validator = Draft202012Validator(encounter_schema)
if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -973,6 +1253,24 @@ def main() -> int:
print(f"error: no *_npc_behaviors.json files under {NPC_BEHAVIORS_DIR}", file=sys.stderr)
return 1
if not REWARD_TABLES_DIR.is_dir():
print(f"error: missing directory {REWARD_TABLES_DIR}", file=sys.stderr)
return 1
reward_table_files = sorted(REWARD_TABLES_DIR.glob("*_reward_tables.json"))
if not reward_table_files:
print(f"error: no *_reward_tables.json files under {REWARD_TABLES_DIR}", file=sys.stderr)
return 1
if not ENCOUNTERS_DIR.is_dir():
print(f"error: missing directory {ENCOUNTERS_DIR}", file=sys.stderr)
return 1
encounter_files = sorted(ENCOUNTERS_DIR.glob("*_encounters.json"))
if not encounter_files:
print(f"error: no *_encounters.json files under {ENCOUNTERS_DIR}", file=sys.stderr)
return 1
seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {}
errors = 0
@ -1161,7 +1459,7 @@ def main() -> int:
print(slice3_recipe_err, file=sys.stderr)
return 1
ability_errors, ability_seen_ids = _validate_ability_catalogs(
ability_errors, ability_seen_ids, ability_id_to_row = _validate_ability_catalogs(
ability_files=ability_files,
ability_validator=ability_validator,
)
@ -1169,9 +1467,9 @@ def main() -> int:
print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr)
return 1
e5m1_ability_err = _prototype_e5m1_ability_gate(ability_seen_ids)
if e5m1_ability_err:
print(e5m1_ability_err, file=sys.stderr)
ability_catalog_err = _prototype_ability_catalog_gate(ability_seen_ids)
if ability_catalog_err:
print(ability_catalog_err, file=sys.stderr)
return 1
npc_behavior_errors, npc_behavior_seen_ids, npc_behavior_id_to_row = _validate_npc_behavior_catalogs(
@ -1192,6 +1490,52 @@ def main() -> int:
print(e5m2_npc_behavior_numeric_err, file=sys.stderr)
return 1
e5m2_attack_ability_err = _prototype_e5m2_npc_behavior_attack_ability_gate(
npc_behavior_id_to_row,
ability_id_to_row,
)
if e5m2_attack_ability_err:
print(e5m2_attack_ability_err, file=sys.stderr)
return 1
reward_table_errors, reward_table_seen_ids, reward_table_id_to_row = _validate_reward_table_catalogs(
reward_table_files=reward_table_files,
reward_table_validator=reward_table_validator,
known_item_ids=frozenset(item_seen_ids.keys()),
)
if reward_table_errors:
print(f"content validation failed with {reward_table_errors} error(s)", file=sys.stderr)
return 1
e5m3_reward_table_err = _prototype_e5m3_reward_table_gate(reward_table_seen_ids)
if e5m3_reward_table_err:
print(e5m3_reward_table_err, file=sys.stderr)
return 1
e5m3_reward_grant_err = _prototype_e5m3_reward_grant_content_gate(reward_table_id_to_row)
if e5m3_reward_grant_err:
print(e5m3_reward_grant_err, file=sys.stderr)
return 1
encounter_errors, encounter_seen_ids, encounter_id_to_row = _validate_encounter_catalogs(
encounter_files=encounter_files,
encounter_validator=encounter_validator,
known_reward_table_ids=frozenset(reward_table_seen_ids.keys()),
)
if encounter_errors:
print(f"content validation failed with {encounter_errors} error(s)", file=sys.stderr)
return 1
e5m3_encounter_err = _prototype_e5m3_encounter_gate(encounter_seen_ids)
if e5m3_encounter_err:
print(e5m3_encounter_err, file=sys.stderr)
return 1
e5m3_encounter_cross_ref_err = _prototype_e5m3_encounter_cross_ref_gate(encounter_id_to_row)
if e5m3_encounter_cross_ref_err:
print(e5m3_encounter_cross_ref_err, file=sys.stderr)
return 1
print(
"content OK: "
f"{len(skill_files)} skill catalog file(s), "
@ -1203,12 +1547,16 @@ def main() -> int:
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(reward_table_files)} reward table catalog file(s), "
f"{len(encounter_files)} encounter 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(reward_table_seen_ids)} unique reward table id(s), "
f"{len(encounter_seen_ids)} unique encounter id(s), "
f"{len(track_skill_ids)} mastery track(s)"
)
return 0

View File

@ -652,6 +652,49 @@ public sealed class AbilityCastApiTests
Assert.Null(fifth.CombatResolution);
}
[Fact]
public async Task PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = PulseCastRequest();
for (var i = 0; i < 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));
}
// Act
var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
fourthResponse.EnsureSuccessStatusCode();
var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
factory.FakeClock.Advance(TimeSpan.FromSeconds(30));
_ = await client.GetAsync("/game/world/npc-runtime-snapshot");
var healthAfterWaitResponse = await client.GetAsync("/game/players/dev-local-1/combat-health");
// Assert
var fourth = await fourthResponse.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.NotNull(fourth);
Assert.True(fourth!.CombatResolution!.TargetDefeated);
snapshotResponse.EnsureSuccessStatusCode();
var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
Assert.NotNull(snapshotBody);
var melee = snapshotBody!.NpcInstances.Single(
row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal("idle", melee.State);
Assert.Null(melee.AggroHolderPlayerId);
Assert.Null(melee.ActiveTelegraph);
healthAfterWaitResponse.EnsureSuccessStatusCode();
var healthBody = await healthAfterWaitResponse.Content.ReadFromJsonAsync<PlayerCombatHealthResponse>();
Assert.NotNull(healthBody);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, healthBody!.CurrentHp);
}
[Fact]
public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression()
{

View File

@ -21,6 +21,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_pulse",
"displayName": "Prototype Pulse",
"baseDamage": 25,
"maxRange": 6.0,
"cooldownSeconds": 3.0,
"abilityKind": "attack"
},
@ -28,6 +29,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_guard",
"displayName": "Prototype Guard",
"baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 6.0,
"abilityKind": "utility"
},
@ -35,6 +37,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_dash",
"displayName": "Prototype Dash",
"baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 4.0,
"abilityKind": "movement"
},
@ -42,6 +45,40 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_burst",
"displayName": "Prototype Burst",
"baseDamage": 40,
"maxRange": 6.0,
"cooldownSeconds": 5.0,
"abilityKind": "attack"
}
]
}
""";
private const string ValidNpcAttackAbilitiesJson =
"""
{
"schemaVersion": 1,
"abilities": [
{
"id": "prototype_npc_melee_strike",
"displayName": "NPC Melee Strike",
"baseDamage": 15,
"maxRange": 2.0,
"cooldownSeconds": 3.0,
"abilityKind": "attack"
},
{
"id": "prototype_npc_ranged_shot",
"displayName": "NPC Ranged Shot",
"baseDamage": 12,
"maxRange": 10.0,
"cooldownSeconds": 4.0,
"abilityKind": "attack"
},
{
"id": "prototype_npc_elite_slam",
"displayName": "NPC Elite Slam",
"baseDamage": 25,
"maxRange": 4.0,
"cooldownSeconds": 5.0,
"abilityKind": "attack"
}
@ -64,6 +101,15 @@ public class AbilityDefinitionCatalogLoaderTests
private static void WriteCatalog(string abilitiesDir, string catalogJson) =>
File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8);
private static void WriteFullPrototypeCatalog(string abilitiesDir)
{
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
File.WriteAllText(
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
ValidNpcAttackAbilitiesJson,
Encoding.UTF8);
}
private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId)
{
var abilities = catalogRoot["abilities"] as JsonArray
@ -85,12 +131,12 @@ public class AbilityDefinitionCatalogLoaderTests
{
// Arrange
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
WriteFullPrototypeCatalog(abilitiesDir);
// Act
var catalog = LoadCatalog(abilitiesDir, schemaPath);
// Assert
Assert.Equal(4, catalog.DistinctAbilityCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.Equal(7, catalog.DistinctAbilityCount);
Assert.Equal(2, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse));
Assert.NotNull(pulse);
Assert.Equal(25, pulse!.BaseDamage);
@ -136,7 +182,7 @@ public class AbilityDefinitionCatalogLoaderTests
{
// Arrange
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson);
WriteFullPrototypeCatalog(abilitiesDir);
File.WriteAllText(
Path.Combine(abilitiesDir, "extra_abilities.json"),
"""
@ -147,6 +193,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_pulse",
"displayName": "Duplicate Pulse",
"baseDamage": 1,
"maxRange": 6.0,
"cooldownSeconds": 1.0
}
]
@ -171,11 +218,15 @@ public class AbilityDefinitionCatalogLoaderTests
?? throw new InvalidOperationException("expected abilities array");
abilities.RemoveAt(abilities.Count - 1);
WriteCatalog(abilitiesDir, root.ToJsonString());
File.WriteAllText(
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
ValidNpcAttackAbilitiesJson,
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
@ -187,11 +238,15 @@ public class AbilityDefinitionCatalogLoaderTests
?? throw new InvalidOperationException("expected object root");
GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra";
WriteCatalog(abilitiesDir, root.ToJsonString());
File.WriteAllText(
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
ValidNpcAttackAbilitiesJson,
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal);
Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal);
}
@ -316,7 +371,7 @@ public class AbilityDefinitionCatalogLoaderTests
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = factory.Services.GetRequiredService<AbilityDefinitionCatalog>();
Assert.Equal(4, catalog.DistinctAbilityCount);
Assert.Equal(7, catalog.DistinctAbilityCount);
Assert.True(catalog.TryGetAbility("prototype_burst", out var burst));
Assert.Equal(40, burst!.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds);

View File

@ -27,6 +27,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -52,6 +53,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Burst",
40,
5.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -75,6 +77,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -96,6 +99,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -117,6 +121,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -138,6 +143,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -159,6 +165,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -180,6 +187,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -201,24 +209,28 @@ public class AbilityDefinitionRegistryTests
"Prototype Burst",
40,
5.0,
6.0,
"attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash",
0,
4.0,
6.0,
"movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard",
0,
6.0,
6.0,
"utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
@ -249,6 +261,10 @@ public class AbilityDefinitionRegistryTests
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"),
Path.Combine(abilitiesDir, "prototype_abilities.json"),
overwrite: true);
File.Copy(
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_npc_abilities.json"),
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
overwrite: true);
var loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
var registry = new AbilityDefinitionRegistry(loaded);
// Act
@ -274,7 +290,7 @@ public class AbilityDefinitionRegistryTests
Assert.NotNull(burst);
Assert.Equal(40, burst!.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds);
Assert.Equal(4, list.Count);
Assert.Equal(7, list.Count);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id);
}
@ -313,7 +329,7 @@ public class AbilityDefinitionRegistryTests
Assert.Null(missing);
Assert.True(normalized);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId);
Assert.Equal(4, list.Count);
Assert.Equal(7, list.Count);
Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0);
}
}

View File

@ -8,17 +8,20 @@ namespace NeonSprawl.Server.Tests.Game.Combat;
public class AbilityDefinitionsWorldApiTests
{
/// <summary>Frozen prototype four in registry id order (ordinal). Keep in sync with Bruno.</summary>
public static readonly string[] FrozenFourInIdOrder =
/// <summary>Frozen prototype seven in registry id order (ordinal). Keep in sync with Bruno.</summary>
public static readonly string[] FrozenSevenInIdOrder =
[
"prototype_burst",
"prototype_dash",
"prototype_guard",
"prototype_npc_elite_slam",
"prototype_npc_melee_strike",
"prototype_npc_ranged_shot",
"prototype_pulse",
];
[Fact]
public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenFourInIdOrder()
public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenSevenInIdOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
@ -31,30 +34,38 @@ public class AbilityDefinitionsWorldApiTests
Assert.NotNull(body);
Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Abilities);
Assert.Equal(4, body.Abilities.Count);
Assert.Equal(7, body.Abilities.Count);
var ids = body.Abilities.Select(static a => a.Id).ToList();
Assert.Equal(FrozenFourInIdOrder, ids);
Assert.Equal(FrozenSevenInIdOrder, ids);
var pulse = body.Abilities.Single(a => a.Id == "prototype_pulse");
Assert.Equal("Prototype Pulse", pulse.DisplayName);
Assert.Equal(25, pulse.BaseDamage);
Assert.Equal(3.0, pulse.CooldownSeconds);
Assert.Equal(6.0, pulse.MaxRange);
Assert.Equal("attack", pulse.AbilityKind);
var burst = body.Abilities.Single(a => a.Id == "prototype_burst");
Assert.Equal("Prototype Burst", burst.DisplayName);
Assert.Equal(40, burst.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds);
Assert.Equal(6.0, burst.MaxRange);
Assert.Equal("attack", burst.AbilityKind);
var guard = body.Abilities.Single(a => a.Id == "prototype_guard");
Assert.Equal("utility", guard.AbilityKind);
Assert.Equal(0, guard.BaseDamage);
Assert.Equal(6.0, guard.CooldownSeconds);
Assert.Equal(6.0, guard.MaxRange);
var dash = body.Abilities.Single(a => a.Id == "prototype_dash");
Assert.Equal("movement", dash.AbilityKind);
Assert.Equal(0, dash.BaseDamage);
Assert.Equal(4.0, dash.CooldownSeconds);
Assert.Equal(6.0, dash.MaxRange);
var meleeStrike = body.Abilities.Single(a => a.Id == "prototype_npc_melee_strike");
Assert.Equal(15, meleeStrike.BaseDamage);
Assert.Equal(2.0, meleeStrike.MaxRange);
}
}

View File

@ -19,24 +19,28 @@ public sealed class CombatOperationsTests
"Prototype Burst",
40,
5.0,
6.0,
"attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash",
0,
4.0,
6.0,
"movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard",
0,
6.0,
6.0,
"utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
};
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1);

View File

@ -1,23 +1,38 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class NpcAttackOperationsTests
{
private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) =>
new(Options.Create(new GamePositionOptions
{
DevPlayerId = "dev-local-1",
DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z },
}));
[Fact]
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches()
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatchesAndInRange()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
@ -26,19 +41,71 @@ public sealed class NpcAttackOperationsTests
Assert.Equal(85, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderOutOfAbilityMaxRange()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
Assert.False(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderBeyondAbilityMaxRangeButInsideAggroRadius()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-10.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
Assert.False(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
_ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
@ -51,14 +118,18 @@ public sealed class NpcAttackOperationsTests
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
@ -68,17 +139,21 @@ public sealed class NpcAttackOperationsTests
}
[Fact]
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero()
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAbilityBaseDamageZero()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 0,
PrototypeAbilityRegistry.PrototypeGuard,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert

View File

@ -26,7 +26,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 16.0,
"telegraphWindupSeconds": 1.5,
"attackDamage": 15,
"attackCooldownSeconds": 3.0
"attackCooldownSeconds": 3.0,
"attackAbilityId": "prototype_npc_melee_strike"
},
{
"id": "prototype_ranged_control",
@ -37,7 +38,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 20.0,
"telegraphWindupSeconds": 2.0,
"attackDamage": 12,
"attackCooldownSeconds": 4.0
"attackCooldownSeconds": 4.0,
"attackAbilityId": "prototype_npc_ranged_shot"
},
{
"id": "prototype_elite_mini_boss",
@ -48,7 +50,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 18.0,
"telegraphWindupSeconds": 2.5,
"attackDamage": 25,
"attackCooldownSeconds": 5.0
"attackCooldownSeconds": 5.0,
"attackAbilityId": "prototype_npc_elite_slam"
}
]
}
@ -159,7 +162,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 2.0,
"telegraphWindupSeconds": 1.0,
"attackDamage": 1,
"attackCooldownSeconds": 1.0
"attackCooldownSeconds": 1.0,
"attackAbilityId": "prototype_npc_melee_strike"
}
]
}

View File

@ -1,6 +1,7 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests;
using Xunit;
@ -25,7 +26,8 @@ public class NpcBehaviorDefinitionRegistryTests
16.0,
1.5,
15,
3.0);
3.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike);
private static NpcBehaviorDefRow RangedRow() =>
new(
@ -37,7 +39,8 @@ public class NpcBehaviorDefinitionRegistryTests
20.0,
2.0,
12,
4.0);
4.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot);
private static NpcBehaviorDefRow EliteRow() =>
new(
@ -49,7 +52,8 @@ public class NpcBehaviorDefinitionRegistryTests
18.0,
2.5,
25,
5.0);
5.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam);
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists()

View File

@ -43,6 +43,7 @@ public class NpcBehaviorDefinitionsWorldApiTests
Assert.Equal(1.5, melee.TelegraphWindupSeconds);
Assert.Equal(15, melee.AttackDamage);
Assert.Equal(3.0, melee.AttackCooldownSeconds);
Assert.Equal("prototype_npc_melee_strike", melee.AttackAbilityId);
var elite = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss);
Assert.Equal("Elite Mini-Boss", elite.DisplayName);
@ -53,5 +54,6 @@ public class NpcBehaviorDefinitionsWorldApiTests
Assert.Equal(2.5, elite.TelegraphWindupSeconds);
Assert.Equal(25, elite.AttackDamage);
Assert.Equal(5.0, elite.AttackCooldownSeconds);
Assert.Equal("prototype_npc_elite_slam", elite.AttackAbilityId);
}
}

View File

@ -1,5 +1,7 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
@ -9,13 +11,23 @@ public sealed class NpcRuntimeOperationsTests
private static readonly DateTimeOffset T0 =
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IPlayerCombatHealthStore PlayerHealth)
CreateFixture()
private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) =>
new(Options.Create(new GamePositionOptions
{
DevPlayerId = "dev-local-1",
DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z },
}));
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IAbilityDefinitionRegistry Abilities, InMemoryCombatEntityHealthStore CombatHealth, InMemoryPositionStateStore Positions, IPlayerCombatHealthStore PlayerHealth)
CreateFixture(double playerX = -3.0, double playerY = 0.5, double playerZ = -3.0)
{
return (
new InMemoryNpcRuntimeStateStore(),
new InMemoryThreatStateStore(),
PrototypeNpcTestFixtures.CreateBehaviorRegistry(),
PrototypeNpcTestFixtures.CreateAbilityRegistry(),
PrototypeNpcTestFixtures.CreateHealthStore(),
CreatePositionStoreAt(playerX, playerY, playerZ),
new InMemoryPlayerCombatHealthStore());
}
@ -27,6 +39,9 @@ public sealed class NpcRuntimeOperationsTests
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
InMemoryCombatEntityHealthStore combatHealthStore,
InMemoryPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
NpcRuntimeOperations.AdvanceAll(
@ -34,6 +49,9 @@ public sealed class NpcRuntimeOperationsTests
runtimeStore,
threatStore,
behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore,
maxDeltaSeconds);
@ -41,9 +59,9 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
// Act
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -54,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -69,10 +87,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
runtime.LastAdvancedUtc = T0;
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -83,11 +101,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -99,11 +117,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -113,12 +131,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -128,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -146,13 +164,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -164,11 +182,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 10);
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 10);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -180,13 +198,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -201,11 +219,11 @@ public sealed class NpcRuntimeOperationsTests
double attackCooldownSeconds)
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -221,12 +239,12 @@ public sealed class NpcRuntimeOperationsTests
double telegraphWindupSeconds)
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -239,11 +257,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -255,12 +273,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(100), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(100), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5);
// Act
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
@ -272,14 +290,14 @@ public sealed class NpcRuntimeOperationsTests
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
runtime.LastAdvancedUtc = T0.AddHours(1);
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -291,11 +309,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
Advance(T0, runtime, threat, behavior, playerHealth);
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
runtime.LastAdvancedUtc = T0.AddSeconds(10);
// Act
Advance(T0.AddSeconds(5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
}
@ -304,13 +322,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(85, snapshot.CurrentHp);
@ -320,13 +338,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
@ -347,7 +365,8 @@ public sealed class NpcRuntimeOperationsTests
16.0,
1.5,
15,
0.0);
0.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike);
var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 };
var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 };
// Act
@ -359,4 +378,78 @@ public sealed class NpcRuntimeOperationsTests
Assert.False(windupOk);
Assert.True(validOk);
}
[Fact]
public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime()
{
// Arrange
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
// Act
NpcRuntimeOperations.TryStopOnTargetDefeat(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
combatHealth,
threat,
runtime);
// Assert
threat.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threatRow);
Assert.Null(threatRow.AggroHolderPlayerId);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtimeRow);
Assert.Equal(NpcBehaviorState.Idle, runtimeRow.BehaviorState);
Assert.Null(runtimeRow.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated()
{
// Arrange
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
_ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
NpcRuntimeOperations.TryStopOnTargetDefeat(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
combatHealth,
threat,
runtime);
// Act
Advance(T0.AddSeconds(30), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderBeyondAbilityMaxRangeButInsideAggroRadius()
{
// Arrange
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(-10.0, 0.5, -3.0);
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderOutOfAttackRangeAtWindupComplete()
{
// Arrange
var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(10.0, 0.5, 10.0);
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
@ -6,6 +7,65 @@ namespace NeonSprawl.Server.Tests.Game.Npc;
/// <summary>Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91).</summary>
public static class PrototypeNpcTestFixtures
{
public static IAbilityDefinitionRegistry CreateAbilityRegistry()
{
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypeBurst] = new(
PrototypeAbilityRegistry.PrototypeBurst,
"Prototype Burst",
40,
5.0,
6.0,
"attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new(
PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash",
0,
4.0,
6.0,
"movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new(
PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard",
0,
6.0,
6.0,
"utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
6.0,
"attack"),
[PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike] = new(
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
"NPC Melee Strike",
15,
3.0,
2.0,
"attack"),
[PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot] = new(
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot,
"NPC Ranged Shot",
12,
4.0,
10.0,
"attack"),
[PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam] = new(
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam,
"NPC Elite Slam",
25,
5.0,
4.0,
"attack"),
};
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 2);
return new AbilityDefinitionRegistry(catalog);
}
public static INpcBehaviorDefinitionRegistry CreateBehaviorRegistry()
{
var byId = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
@ -19,7 +79,8 @@ public static class PrototypeNpcTestFixtures
16.0,
1.5,
15,
3.0),
3.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike),
[PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new(
PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
"Ranged Control",
@ -29,7 +90,8 @@ public static class PrototypeNpcTestFixtures
20.0,
2.0,
12,
4.0),
4.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot),
[PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new(
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
"Elite Mini-Boss",
@ -39,7 +101,8 @@ public static class PrototypeNpcTestFixtures
18.0,
2.5,
25,
5.0),
5.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam),
};
var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1);

View File

@ -66,6 +66,7 @@ public static class AbilityCastApi
ICombatEntityHealthStore healthStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
INpcRuntimeStateStore npcRuntimeStore,
IPlayerGigProgressionStore gigStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
@ -243,6 +244,11 @@ public static class AbilityCastApi
if (combatResult.TargetDefeated)
{
NpcRuntimeOperations.TryStopOnTargetDefeat(
lookupKey,
healthStore,
threatStore,
npcRuntimeStore);
CombatDefeatGigXpGrant.GrantOnCombatDefeat(
id.Trim(),
gigStore,

View File

@ -6,4 +6,5 @@ public sealed record AbilityDefRow(
string DisplayName,
int BaseDamage,
double CooldownSeconds,
double MaxRange,
string? AbilityKind);

View File

@ -140,11 +140,12 @@ public static class AbilityDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
var maxRange = (rowObj["maxRange"] as JsonValue)!.GetValue<double>();
string? abilityKind = null;
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
abilityKind = abilityKindValue.GetValue<string>();
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind);
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, maxRange, abilityKind);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)

View File

@ -30,6 +30,9 @@ public sealed class AbilityDefinitionJson
[JsonPropertyName("cooldownSeconds")]
public required double CooldownSeconds { get; init; }
[JsonPropertyName("maxRange")]
public required double MaxRange { get; init; }
[JsonPropertyName("abilityKind")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? AbilityKind { get; init; }

View File

@ -20,6 +20,7 @@ public static class AbilityDefinitionsWorldApi
DisplayName = d.DisplayName,
BaseDamage = d.BaseDamage,
CooldownSeconds = d.CooldownSeconds,
MaxRange = d.MaxRange,
AbilityKind = d.AbilityKind,
});
}

View File

@ -1,22 +1,32 @@
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95).</summary>
/// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95 / NEO-98).</summary>
public static class NpcAttackOperations
{
/// <summary>
/// Applies catalog <paramref name="attackDamage"/> to <paramref name="holderPlayerId"/> when
/// the threat row still names that holder for <paramref name="npcInstanceId"/>.
/// Applies catalog ability <c>baseDamage</c> to <paramref name="holderPlayerId"/> when the threat row still
/// names that holder for <paramref name="npcInstanceId"/> and the holder is within the attack ability's
/// <see cref="AbilityDefRow.MaxRange"/> of the NPC anchor.
/// </summary>
public static bool TryResolveTelegraphComplete(
string npcInstanceId,
string holderPlayerId,
int attackDamage,
string attackAbilityId,
IAbilityDefinitionRegistry abilityRegistry,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore,
IThreatStateStore threatStore)
{
if (attackDamage <= 0)
if (!abilityRegistry.TryGetDefinition(attackAbilityId, out var ability))
{
return false;
}
if (ability.BaseDamage <= 0)
{
return true;
}
@ -29,7 +39,12 @@ public static class NpcAttackOperations
return false;
}
if (!playerHealthStore.TryApplyDamage(holderPlayerId, attackDamage, out var snapshot))
if (!IsHolderWithinAbilityMaxRange(npcInstanceId, holderPlayerId, ability.MaxRange, positionStore))
{
return false;
}
if (!playerHealthStore.TryApplyDamage(holderPlayerId, ability.BaseDamage, out var snapshot))
{
return false;
}
@ -41,4 +56,25 @@ public static class NpcAttackOperations
return true;
}
internal static bool IsHolderWithinAbilityMaxRange(
string npcInstanceId,
string holderPlayerId,
double maxRange,
IPositionStateStore positionStore)
{
if (maxRange <= 0 ||
!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
!positionStore.TryGetPosition(holderPlayerId, out var playerSnap))
{
return false;
}
return HorizontalReach.IsWithinHorizontalRadius(
playerSnap.X,
playerSnap.Z,
entry.X,
entry.Z,
maxRange);
}
}

View File

@ -3,8 +3,9 @@ using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c> / <c>_prototype_e5m1_ability_gate</c>.
/// Prototype ability roster gate (NEO-76 / NEO-77 / NEO-98), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c>, <c>PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS</c>, and
/// <c>_prototype_ability_catalog_gate</c>.
/// </summary>
public static class PrototypeE5M1AbilityCatalogRules
{
@ -18,15 +19,29 @@ public static class PrototypeE5M1AbilityCatalogRules
],
StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the E5M1 ability contract fails, otherwise <see langword="null"/>.</summary>
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedNpcAttackAbilityIds = FrozenSet.ToFrozenSet(
[
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam,
],
StringComparer.Ordinal);
/// <summary>Union of player hotbar abilities and NPC attack abilities loaded at startup.</summary>
public static readonly FrozenSet<string> ExpectedCatalogAbilityIds = ExpectedAbilityIds
.Union(ExpectedNpcAttackAbilityIds)
.ToFrozenSet(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the prototype ability catalog contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> abilityIdToSourceFile)
{
var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedAbilityIds))
if (!ids.SetEquals(ExpectedCatalogAbilityIds))
{
return
"error: prototype E5M1 expects exactly ability ids " +
$"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
"error: prototype ability catalog expects exactly ability ids " +
$"[{string.Join(", ", ExpectedCatalogAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Stable prototype NPC attack ability id constants (NEO-98). Allowlist validation uses <see cref="IAbilityDefinitionRegistry"/>.</summary>
public static class PrototypeNpcAttackAbilityRegistry
{
public const string PrototypeNpcMeleeStrike = "prototype_npc_melee_strike";
public const string PrototypeNpcRangedShot = "prototype_npc_ranged_shot";
public const string PrototypeNpcEliteSlam = "prototype_npc_elite_slam";
}

View File

@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Npc;
@ -29,7 +30,17 @@ public static class NpcBehaviorCatalogServiceCollectionExtensions
opts.NpcBehaviorDefSchemaPath,
hostEnv.ContentRootPath);
return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger);
var catalog = NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger);
var abilityRegistry = sp.GetRequiredService<IAbilityDefinitionRegistry>();
var crossRefErr = PrototypeE5M2NpcBehaviorCatalogRules.TryGetAttackAbilityCrossRefError(
catalog.ById,
abilityRegistry);
if (crossRefErr is not null)
{
throw new InvalidOperationException(crossRefErr);
}
return catalog;
});
services.AddSingleton<INpcBehaviorDefinitionRegistry>(sp =>

View File

@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow(
double LeashRadius,
double TelegraphWindupSeconds,
int AttackDamage,
double AttackCooldownSeconds);
double AttackCooldownSeconds,
string AttackAbilityId);

View File

@ -152,6 +152,7 @@ public static class NpcBehaviorDefinitionCatalogLoader
var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>();
var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
var attackAbilityId = (rowObj["attackAbilityId"] as JsonValue)!.GetValue<string>();
return new NpcBehaviorDefRow(
id,
@ -162,7 +163,8 @@ public static class NpcBehaviorDefinitionCatalogLoader
leashRadius,
telegraphWindupSeconds,
attackDamage,
attackCooldownSeconds);
attackCooldownSeconds,
attackAbilityId);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)

View File

@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson
[JsonPropertyName("attackCooldownSeconds")]
public required double AttackCooldownSeconds { get; init; }
[JsonPropertyName("attackAbilityId")]
public required string AttackAbilityId { get; init; }
}

View File

@ -25,6 +25,7 @@ public static class NpcBehaviorDefinitionsWorldApi
TelegraphWindupSeconds = d.TelegraphWindupSeconds,
AttackDamage = d.AttackDamage,
AttackCooldownSeconds = d.AttackCooldownSeconds,
AttackAbilityId = d.AttackAbilityId,
});
}

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Npc;
@ -20,6 +21,9 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore combatHealthStore,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
{
@ -40,6 +44,9 @@ public static class NpcRuntimeOperations
runtimeStore,
threatStore,
behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore);
}
@ -59,6 +66,9 @@ public static class NpcRuntimeOperations
runtimeStore,
threatStore,
behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore);
}
@ -78,6 +88,9 @@ public static class NpcRuntimeOperations
runtimeStore,
threatStore,
behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore);
}
@ -92,6 +105,27 @@ public static class NpcRuntimeOperations
runtimeStore.ResetAllPrototypeRows();
}
/// <summary>
/// Clears aggro and runtime combat state when the NPC combat-target row is <see cref="CombatEntityHealthSnapshot.Defeated"/>.
/// </summary>
public static void TryStopOnTargetDefeat(
string npcInstanceId,
ICombatEntityHealthStore combatHealthStore,
IThreatStateStore threatStore,
INpcRuntimeStateStore runtimeStore)
{
var npcKey = NormalizeNpcId(npcInstanceId);
if (npcKey.Length == 0 ||
!combatHealthStore.TryGet(npcKey, out var combatHp) ||
!combatHp.Defeated)
{
return;
}
threatStore.TryClearHolder(npcKey);
WriteIdle(runtimeStore, npcKey);
}
private static void AdvanceOne(
string npcInstanceId,
DateTimeOffset windowStart,
@ -99,6 +133,9 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore combatHealthStore,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore)
{
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
@ -117,6 +154,19 @@ public static class NpcRuntimeOperations
return;
}
if (combatHealthStore.TryGet(npcInstanceId, out var combatHp) && combatHp.Defeated)
{
if (TryHasAggroHolder(npcInstanceId, threatStore, out _) ||
snapshot.BehaviorState != NpcBehaviorState.Idle ||
snapshot.ActiveTelegraph is not null)
{
threatStore.TryClearHolder(npcInstanceId);
WriteIdle(runtimeStore, npcInstanceId);
}
return;
}
if (!TryHasAggroHolder(npcInstanceId, threatStore, out var aggroHolderPlayerId))
{
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
@ -212,7 +262,9 @@ public static class NpcRuntimeOperations
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
behavior.AttackAbilityId,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
WriteState(
@ -233,7 +285,9 @@ public static class NpcRuntimeOperations
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
behavior.AttackAbilityId,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
WriteState(
@ -326,4 +380,7 @@ public static class NpcRuntimeOperations
npcInstanceId,
new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph));
}
private static string NormalizeNpcId(string? raw) =>
raw?.Trim().ToLowerInvariant() ?? string.Empty;
}

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Npc;
@ -14,6 +15,9 @@ public static class NpcRuntimeSnapshotWorldApi
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore combatHealthStore,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore) =>
{
var now = clock.GetUtcNow();
@ -22,6 +26,9 @@ public static class NpcRuntimeSnapshotWorldApi
runtimeStore,
threatStore,
behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore);
return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
});

View File

@ -1,5 +1,7 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
@ -47,4 +49,30 @@ public static class PrototypeE5M2NpcBehaviorCatalogRules
return null;
}
/// <summary>
/// Returns a human-readable error when <paramref name="attackAbilityId"/> is missing from the ability catalog
/// or <c>attackDamage</c> does not match ability <c>baseDamage</c>, otherwise <see langword="null"/>.
/// </summary>
public static string? TryGetAttackAbilityCrossRefError(
IReadOnlyDictionary<string, NpcBehaviorDefRow> rowsById,
IAbilityDefinitionRegistry abilityRegistry)
{
foreach (var (behaviorId, row) in rowsById)
{
if (!abilityRegistry.TryGetDefinition(row.AttackAbilityId, out var ability))
{
return
$"error: npc behavior '{behaviorId}': attackAbilityId '{row.AttackAbilityId}' missing from ability catalog";
}
if (row.AttackDamage != ability.BaseDamage)
{
return
$"error: npc behavior '{behaviorId}': attackDamage {row.AttackDamage} must match ability '{row.AttackAbilityId}' baseDamage {ability.BaseDamage}";
}
}
return null;
}
}

View File

@ -82,7 +82,7 @@ On success, **Information** logs include the resolved recipes directory path, di
## Ability catalog (`content/abilities`, NEO-77)
On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype ability catalog** seven-id roster gate (four player hotbar abilities plus three NPC attack abilities; same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
| Config | Meaning |
|--------|---------|
@ -108,7 +108,7 @@ On success, **Information** logs include the resolved npc-behaviors directory pa
## NPC behavior definitions (NEO-90)
**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, and **`attackCooldownSeconds`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.
**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.
```bash
curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions"
@ -161,7 +161,9 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In
| Rule | Behavior |
|------|----------|
| **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. |
| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **2 m**, ranged shot **10 m**, elite slam **4 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). |
| **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. |
| **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). |
| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
| **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
@ -176,7 +178,7 @@ Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** +
| **`idle`** | No aggro holder; no telegraph. |
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. |
| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies catalog **`attackDamage`** to aggro holder ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). |
| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies the behavior's **`attackAbilityId`** **`baseDamage`** when holder is within that ability's **`maxRange`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)). |
| **`recover`** | Post-attack cooldown wait before next windup. |
| Rule | Behavior |
@ -190,7 +192,7 @@ Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.m
### NPC runtime telemetry hooks (NEO-96)
Comment-only hook sites in **`NpcRuntimeOperations.AdvanceAll`** for future E9.M1 catalog events — **`telegraph_fired`** at each **`telegraph_windup`** entry (aggro→telegraph and recover→telegraph); **`npc_state_transition`** on every committed state change during **`AdvanceAll`** (including holder clear → **`idle`**). Dev fixture **`ResetAllPrototypeRows`** writes idle directly and bypasses **`AdvanceOne`** hooks (optional E9.M1 anchor noted on reset path). **`TODO(E9.M1)`** — no production ingest. Snapshot GET invokes **`AdvanceAll`** before read (no duplicate API-layer hooks). Plan: [NEO-96 implementation plan](../../docs/plans/NEO-96-implementation-plan.md).
Comment-only hook sites in **`NpcRuntimeOperations.AdvanceAll`** for future E9.M1 catalog events ([Epic 5 Slice 2](../../docs/decomposition/epics/epic_05_pve_combat.md#epic-5-slice-2)) **`telegraph_fired`** at each **`telegraph_windup`** entry (aggro→telegraph and recover→telegraph); **`npc_state_transition`** on every committed state change during **`AdvanceAll`** (including holder clear → **`idle`**). Dev fixture **`ResetAllPrototypeRows`** writes idle directly and bypasses **`AdvanceOne`** hooks (optional E9.M1 anchor noted on reset path). **`TODO(E9.M1)`** — no production ingest. Snapshot GET invokes **`AdvanceAll`** before read (no duplicate API-layer hooks). Plan: [NEO-96 implementation plan](../../docs/plans/NEO-96-implementation-plan.md).
## NPC runtime snapshot (NEO-94)
@ -208,7 +210,7 @@ Comment-only hook sites in **`NpcRuntimeOperations.AdvanceAll`** for future E9.M
curl -sS -i "http://localhost:5253/game/world/npc-runtime-snapshot"
```
**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, catalog **`attackDamage`** applies to the aggro holder via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)).
**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, the behavior's **`attackAbilityId`** **`baseDamage`** applies to the aggro holder when within that ability's **`maxRange`** via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)).
## Session player combat HP (NEO-95)
@ -217,7 +219,7 @@ Session player HP for incoming NPC damage lives in **`Game/Combat/`** as **`IPla
| Rule | Behavior |
|------|----------|
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. |
| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes and aggro holder still matches. |
| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes, aggro holder still matches, and holder is within the attack ability's **`maxRange`** of the NPC anchor. |
| **Holder re-check** | No damage when holder cleared during windup (leash clear). |
| **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. |
@ -245,7 +247,7 @@ Plan: [NEO-81 implementation plan](../../docs/plans/NEO-81-implementation-plan.m
### Combat telemetry hooks (NEO-84)
Comment-only hook sites in **`CombatOperations.TryResolve`** for future E9.M1 catalog events ([Epic 5 Slice 1](../../docs/decomposition/epics/epic_05_pve_combat.md#slice-1---combat-rules-mvp)). **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/ability-cast`** calls the engine only (no duplicate API-layer hooks for these names).
Comment-only hook sites in **`CombatOperations.TryResolve`** for future E9.M1 catalog events ([Epic 5 Slice 1](../../docs/decomposition/epics/epic_05_pve_combat.md#epic-5-slice-1)). **`TODO(E9.M1)`** — no production ingest. HTTP **`POST …/ability-cast`** calls the engine only (no duplicate API-layer hooks for these names).
- **`ability_used`** — on every successful resolve (zero-damage abilities included).
- **`enemy_defeat`** — only when lethal damage drives **`after.Defeated`** to **`true`** (re-hit on defeated targets denies at **`target_defeated`** before damage).