Compare commits
22 Commits
c3dee2b721
...
7c0e2e8cd6
| Author | SHA1 | Date |
|---|---|---|
|
|
7c0e2e8cd6 | |
|
|
ebf385d658 | |
|
|
01b7e23c5e | |
|
|
ee5e1a0caf | |
|
|
4f16c848db | |
|
|
cb86645524 | |
|
|
d7c3ede5a7 | |
|
|
db9ddb9fe0 | |
|
|
2f1b7f10be | |
|
|
e6b8386102 | |
|
|
04126693e1 | |
|
|
b77b7f8fbb | |
|
|
bc1357cf94 | |
|
|
44fb4adbe5 | |
|
|
99aa2b455f | |
|
|
b9e2d2f4c9 | |
|
|
3169106f28 | |
|
|
332cd4445f | |
|
|
b03128a3b0 | |
|
|
817ed26304 | |
|
|
ae8b01d5e3 | |
|
|
ceef54cd23 |
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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-92–NEO-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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 1–21 completable in one session without Bruno/curl.
|
||||
- [ ] Melee telegraph countdown readable **before** player HP drops to **85/100** (steps 7–8).
|
||||
- [ ] All three archetypes show **`defeated`** in **`CombatTargetHpLabel`** (steps 9, 14, 18).
|
||||
- [ ] Aggro on first damaging cast feels deterministic (no silent failures).
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 **1–12** 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 (1–2 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)
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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-92–NEO-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-92–NEO-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-92–NEO-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** |
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 19–21), 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 3–4 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 19–20).
|
||||
|
||||
**Optional:** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) incoming-threat row regression.
|
||||
|
|
@ -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-92–NEO-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 8–10) 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-06–E5M2-11 inline — optional consistency pass.~~ **Done.** Linear row lists E5M2-01–E5M2-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 3–10 (telegraph visible **before** **85/100**), Ranged + Elite defeat with telegraph visible, regression steps 19–21 (**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.
|
||||
|
|
@ -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 50–63) 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 1–3 telemetry hook lists vs plan AC checklists; E5M3 kickoff **Gig XP** row vs `progression.md` combat → gig seam.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ public sealed record AbilityDefRow(
|
|||
string DisplayName,
|
||||
int BaseDamage,
|
||||
double CooldownSeconds,
|
||||
double MaxRange,
|
||||
string? AbilityKind);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public static class AbilityDefinitionsWorldApi
|
|||
DisplayName = d.DisplayName,
|
||||
BaseDamage = d.BaseDamage,
|
||||
CooldownSeconds = d.CooldownSeconds,
|
||||
MaxRange = d.MaxRange,
|
||||
AbilityKind = d.AbilityKind,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 + "'"))}]";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow(
|
|||
double LeashRadius,
|
||||
double TelegraphWindupSeconds,
|
||||
int AttackDamage,
|
||||
double AttackCooldownSeconds);
|
||||
double AttackCooldownSeconds,
|
||||
string AttackAbilityId);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson
|
|||
|
||||
[JsonPropertyName("attackCooldownSeconds")]
|
||||
public required double AttackCooldownSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("attackAbilityId")]
|
||||
public required string AttackAbilityId { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public static class NpcBehaviorDefinitionsWorldApi
|
|||
TelegraphWindupSeconds = d.TelegraphWindupSeconds,
|
||||
AttackDamage = d.AttackDamage,
|
||||
AttackCooldownSeconds = d.AttackCooldownSeconds,
|
||||
AttackAbilityId = d.AttackAbilityId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue