Merge pull request #115 from ViPro-Technologies/NEO-81-combat-operations-try-resolve

NEO-81: CombatOperations.TryResolve + CombatResult
pull/116/head
VinPropane 2026-05-25 10:28:52 -04:00 committed by GitHub
commit b8d87bbb54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 600 additions and 5 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M1 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06+ pending (see [dependency register](module_dependency_register.md)) |
| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04 [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) ability-definitions GET landed; E5M1-05 [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) combat entity health store landed; E5M1-06 [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) combat operations + `CombatResult` landed; E5M1-07+ pending (see [dependency register](module_dependency_register.md)) |
| **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) |
## Purpose
@ -54,6 +54,8 @@ See Epic 5 **Slice 1 — Combat rules MVP**: target lock, basic attacks, 46 a
**E5M1-05 (NEO-80):** **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`** — lazy HP init for **`PrototypeTargetRegistry`** dummies at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80).
**E5M1-06 (NEO-81):** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — deterministic catalog damage, defeated pre-check deny (`target_defeated`), unknown ability/target validation ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81).
## Linear backlog (decomposed)
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md).

View File

@ -56,7 +56,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01****E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
| E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01****E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-06** [NEO-81](https://linear.app/neon-sprawl/issue/NEO-81) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) |
| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-07** [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) |
---

View File

@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
| E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned |
| E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned |
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80).
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81).
### Epic 6 — PvP Security

View File

@ -210,8 +210,10 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
**Acceptance criteria**
- [ ] **`prototype_pulse`** against full HP dummy deals catalog damage deterministically.
- [ ] Second resolve on defeated target returns structured deny (no silent no-op).
- [x] **`prototype_pulse`** against full HP dummy deals catalog damage deterministically.
- [x] Second resolve on defeated target returns structured deny (no silent no-op).
**Landed ([NEO-81](https://linear.app/neon-sprawl/issue/NEO-81)):** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — deterministic catalog damage, defeated pre-check deny, unknown ability/target validation ([NEO-81-implementation-plan.md](NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../server/README.md#combat-engine-neo-81).
---

View File

@ -0,0 +1,147 @@
# NEO-81 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-81 |
| **Title** | E5M1-06: CombatOperations.TryResolve + CombatResult |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-81/e5m1-06-combatoperationstryresolve-combatresult |
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-06** |
| **Branch** | `NEO-81-combat-operations-try-resolve` |
| **Precursor** | [NEO-80](https://linear.app/neon-sprawl/issue/NEO-80) — `ICombatEntityHealthStore` (**Done** on `main`); [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` (**Done** on `main`) |
| **Pattern** | [NEO-62](https://linear.app/neon-sprawl/issue/NEO-62) / [NEO-69](https://linear.app/neon-sprawl/issue/NEO-69) — static `*Operations.Try*` + `*Result` envelope + `*ReasonCodes`; NEO-80 store handoff for defeated re-hit deny |
| **Blocks** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — wire `ability-cast` into combat engine + extend cast response |
| **Client counterpart** | None (server-only internal ops); wire **`CombatResolution`** on cast POST is [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82); HP HUD is [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Defeated-target deny scope** | Deny all abilities on defeated target, including zero-damage utility? | **Deny all** — E5M1-07 uses `target_defeated` on cast at defeated dummy regardless of ability kind. | **User:** deny all. |
| **Unknown id validation** | Validate ability + target inside `TryResolve` vs assume E1.M4 pre-validated? | **Validate inside** — backlog requires stable deny for unknown ids; mirrors `GatherOperations` / `CraftOperations` self-contained validation. | **User:** validate inside. |
| **Zero-damage success path** | `TryGet` only vs `TryApplyDamage(target, 0)`? | **Success with `damageDealt = 0`; `TryGet` snapshot only** — no HP mutation when catalog damage is 0; still lazy-inits via `TryGet`. | **User:** success, TryGet only. |
## Goal, scope, and out-of-scope
**Goal:** Provide pure server combat resolution: known ability + known prototype target → deterministic catalog damage, remaining HP, and defeated flag. Return structured denies (no silent no-op) when the target is already defeated or ids are unknown.
**In scope (from Linear + [E5M1-06](E5M1-prototype-backlog.md#e5m1-06--combatoperationstryresolve--combatresult)):**
- `CombatOperations.TryResolve` using **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`**.
- `CombatResult` readonly record struct with **`DamageDealt`**, **`TargetRemainingHp`**, **`TargetDefeated`**, **`Success`**, **`ReasonCode`**.
- `CombatReasonCodes` stable deny strings aligned with E1.M4 / E5M1-07 vocabulary.
- Unit tests (AAA): happy path (`prototype_pulse` → 25 dmg), zero-damage utility success, defeated-target deny (including guard/dash), unknown ability/target denies.
- `server/README.md` combat engine section.
**Out of scope (from Linear + backlog):**
- HTTP wiring / `AbilityCastResponse` extension ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)).
- Per-ability cooldown commit ([NEO-82](https://linear.app/neon-sprawl/issue/NEO-82)).
- Gig XP on defeat ([NEO-44](https://linear.app/neon-sprawl/issue/NEO-44)).
- Telemetry hooks (reserved for follow-up like NEO-64 / NEO-71 pattern).
- Godot / client changes.
- Bruno (no HTTP surface in this story).
## Acceptance criteria checklist
- [x] **`prototype_pulse`** against full-HP dummy deals catalog damage (25) deterministically.
- [x] Second resolve on defeated target returns structured deny (no silent no-op).
- [x] Zero-damage utility abilities succeed with **`damageDealt = 0`** on live targets.
- [x] Unknown ability / unknown target return stable deny codes from **`CombatOperations`**.
- [x] Defeated-target deny applies to zero-damage utility abilities.
## Technical approach
1. **`CombatReasonCodes`** — static constants in `Game/Combat/`:
- **`TargetDefeated = "target_defeated"`** — target HP is already 0 (checked **before** damage application; no store mutation).
- **`UnknownAbility = "unknown_ability"`** — ability id fails **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (matches cast/hotbar vocabulary).
- **`UnknownTarget = "unknown_target"`** — target id fails **`ICombatEntityHealthStore.TryGet`** (store gates **`PrototypeTargetRegistry`** ids; matches E1.M3 targeting vocabulary).
2. **`CombatResult`** — readonly record struct (mirror [`CraftResult`](../../server/NeonSprawl.Server/Game/Crafting/CraftResult.cs)):
- **`Success`** — `true` when damage resolution committed (including zero-damage success).
- **`ReasonCode`** — `null` on success; stable string on deny.
- **`DamageDealt`** — catalog **`AbilityDefRow.BaseDamage`** on success; **0** on deny.
- **`TargetRemainingHp`** — authoritative HP after resolve on success; **null** on deny (or current HP on `target_defeated` deny — see step 4).
- **`TargetDefeated`** — `true` when post-resolve HP is 0 on success; **false** on deny (even when target was already defeated — caller uses **`ReasonCode`**).
3. **`CombatOperations`** — static class (mirror [`GatherOperations`](../../server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs)):
```csharp
public static CombatResult TryResolve(
string abilityId,
string targetId,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore healthStore)
```
- **Ability gate:** `abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility)` → deny **`unknown_ability`** when false.
- **Definition load:** `abilityRegistry.TryGetDefinition(normalizedAbility, out var definition)` — should succeed after normalize; treat miss as **`unknown_ability`** (defensive).
- **Target read:** `healthStore.TryGet(targetId, out var snapshot)` → deny **`unknown_target`** when false (lazy-inits live targets).
- **Defeated pre-check:** when **`snapshot.Defeated`** → deny **`target_defeated`** **without** calling **`TryApplyDamage`** (NEO-80 handoff; applies to all abilities including zero-damage).
- **Zero-damage path:** when **`definition.BaseDamage == 0`** → success with **`DamageDealt = 0`**, **`TargetRemainingHp = snapshot.CurrentHp`**, **`TargetDefeated = snapshot.Defeated`** (always false for live target after pre-check).
- **Damage path:** `healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after)` → success with **`DamageDealt = definition.BaseDamage`**, **`TargetRemainingHp = after.CurrentHp`**, **`TargetDefeated = after.Defeated`**.
- Private **`Deny(string reasonCode)`** helper returns **`Success = false`**, empty damage fields.
4. **`target_defeated` deny payload:** On defeated pre-check, set **`TargetRemainingHp = 0`** (from snapshot) so callers/tests can observe current HP without re-querying store; **`DamageDealt = 0`**, **`TargetDefeated = false`** on envelope (deny is **`ReasonCode`**-driven — same pattern as gather depletion deny carrying **`RemainingGathers`**).
5. **No DI registration** — static ops class; tests construct **`InMemoryCombatEntityHealthStore`** + in-memory registry helper (same as **`AbilityDefinitionRegistryTests`**). Host already registers both dependencies via **`AddAbilityDefinitionCatalog`**.
6. **`server/README.md`** — new **Combat engine (NEO-81)** section after combat entity health: document **`CombatOperations.TryResolve`**, **`CombatResult`** fields, reason-code table, and cross-link **NEO-82** cast wiring.
### Expected prototype resolve values
| Ability | Target | Initial HP | Result |
|---------|--------|------------|--------|
| `prototype_pulse` | `prototype_target_alpha` | 100 (lazy) | Success: **`damageDealt` 25**, **`targetRemainingHp` 75**, **`targetDefeated` false** |
| `prototype_pulse` × 4 | same | — | 4th success: **`targetRemainingHp` 0**, **`targetDefeated` true** |
| `prototype_pulse` | same (defeated) | 0 | Deny: **`target_defeated`**, **`damageDealt` 0** |
| `prototype_guard` | live alpha | 100 | Success: **`damageDealt` 0**, HP unchanged |
| `prototype_guard` | defeated alpha | 0 | Deny: **`target_defeated`** |
| `unknown_ability` | alpha | — | Deny: **`unknown_ability`** |
| `prototype_pulse` | `not_a_target` | — | Deny: **`unknown_target`** |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Combat/CombatReasonCodes.cs` | Stable deny reason strings for combat ops. |
| `server/NeonSprawl.Server/Game/Combat/CombatResult.cs` | Server-internal combat resolution envelope. |
| `server/NeonSprawl.Server/Game/Combat/CombatOperations.cs` | Static **`TryResolve`** orchestrating registry + health store. |
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs` | AAA unit tests for resolve happy/deny paths. |
| `docs/plans/NEO-81-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/README.md` | Document **`CombatOperations.TryResolve`**, **`CombatResult`**, reason codes, and NEO-82 handoff. |
## Tests
| File | Coverage |
|------|----------|
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs` | **Happy path:** `prototype_pulse` on fresh alpha → 25 dmg, 75 HP. **Deterministic chain:** four pulses → defeated on 4th. **Defeated deny:** 5th pulse → **`target_defeated`**, no HP change. **Zero-damage:** `prototype_guard` on live target → success, 0 dmg, HP unchanged. **Defeated + utility:** `prototype_guard` on defeated → **`target_defeated`**. **Unknown ability:** deny **`unknown_ability`**. **Unknown target:** deny **`unknown_target`**. **Burst:** `prototype_burst` → 40 dmg once. Use in-memory registry helper + **`InMemoryCombatEntityHealthStore`**; optional host test resolving registries from **`InMemoryWebApplicationFactory`**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
No Bruno or manual QA for this story (server-only internal ops; no HTTP or client-facing change).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|---------------------|--------|
| **Wire naming: CombatResult vs CombatResolution** | **`CombatResult`** server-internal; NEO-82 promotes to wire **`CombatResolution`** / cast response block (CraftResult → CraftResponse precedent). | **adopted** |
| **Defeated deny on zero-damage** | **Deny all abilities** on defeated target (user kickoff). | **adopted** |
| **Telemetry hook site** | **Defer** comment-only hooks to NEO-82 or dedicated telemetry story when cast path wires in. | **deferred** |
| **Player-as-target / PvP** | **Out of scope** — prototype registry has NPC stubs only; PvP gates are E6.M1. | **adopted** |
## Decisions (kickoff)
- **Defeated pre-check** before damage; no **`TryApplyDamage`** on defeated targets.
- **Zero-damage** abilities succeed via **`TryGet`** only (no store mutation).
- **Unknown ability/target** validated inside **`TryResolve`** with stable reason codes.
- **No HTTP** in NEO-81; cast wiring is [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82).
## Reconciliation (implementation)
- **`CombatReasonCodes`**, **`CombatResult`**, **`CombatOperations.TryResolve`** added under **`Game/Combat/`**.
- Defeated pre-check denies before damage; zero-damage path uses **`TryGet`** only.
- **9** AAA tests in **`CombatOperationsTests`** (all green), including host DI resolve.
- **`server/README.md`** — combat engine section with reason-code table and NEO-82 handoff.

View File

@ -0,0 +1,67 @@
# Code review — NEO-81 CombatOperations.TryResolve + CombatResult
**Date:** 2026-05-25
**Scope:** Branch `NEO-81-combat-operations-try-resolve` · commits `849c34f``ca15e7a` vs `origin/main`
**Base:** `origin/main`
**Follow-up:** Code-review suggestions and nits addressed in a follow-up commit (doc sync, test AAA/assertions, dash smoke).
## Verdict
**Approve with nits**
## Summary
NEO-81 delivers **E5M1-06**: a static `CombatOperations.TryResolve` orchestrating `IAbilityDefinitionRegistry` and `ICombatEntityHealthStore` into a server-internal `CombatResult` envelope with stable deny codes (`unknown_ability`, `unknown_target`, `target_defeated`). Implementation matches kickoff decisions: self-contained id validation, defeated pre-check before any damage (including zero-damage utilities), zero-damage success via `TryGet` only, and `target_defeated` deny carrying `targetRemainingHp = 0` with `targetDefeated = false` on the envelope. Nine AAA unit/host tests cover pulse/guard/burst paths, four-pulse defeat chain, defeated re-hit deny, unknown ids, and DI resolution. `server/README.md` documents the combat engine section and NEO-82 handoff. Residual risk is low — server-only ops with no HTTP surface; module tracking docs and minor test/doc backlog sync should close on merge (same pattern as NEO-80 follow-ups).
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-81-implementation-plan.md`](../plans/NEO-81-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation, and expected resolve table align with code. |
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-06** | ~~**Partially matches** — story AC checkboxes still unchecked; no landed note yet (E5M1-05 pattern).~~ **Matches** — AC checked, landed note added. **Done.** |
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | ~~**Partially matches** — Status still reads “E5M1-06+ pending”; no related implementation slice for NEO-81.~~ **Matches** — Status + E5M1-06 slice entry updated. **Done.** |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | ~~**Partially matches** — E5.M1 note stops at NEO-80; NEO-81 `CombatOperations` not cited.~~ **Matches** — NEO-81 landed note added. **Done.** |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | ~~**Partially matches** — E5.M1 row should add NEO-81 landed after merge.~~ **Matches** — NEO-81 row + links updated. **Done.** |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only internal ops; no client or HTTP changes. |
| [`docs/decomposition/modules/pvp_combat_integration.md`](../decomposition/modules/pvp_combat_integration.md) | **N/A** — prototype NPC targets only; PvP gates out of scope per plan. |
| [`server/README.md`](../../server/README.md) | **Matches** — combat engine section, reason-code table, `CombatResult` field semantics, NEO-82 cross-link. |
| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; cast wire is NEO-82, HUD is NEO-85. |
| Manual QA | **N/A** — plan skip rationale holds (server-only internal ops). |
## Blocking issues
None.
## Suggestions
1. ~~**Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, update **Status** to cite **E5M1-06 NEO-81 landed** (`CombatOperations.TryResolve` + `CombatResult`), and add a **Related implementation slices** entry linking the NEO-81 plan and README combat engine section (mirror NEO-80 landed pattern).~~ **Done.**
2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-81 landed:** `CombatOperations`, `CombatResult`, `CombatReasonCodes`, defeated pre-check deny vocabulary.~~ **Done.**
3. ~~**E5M1 backlog landed note** — In `docs/plans/E5M1-prototype-backlog.md` § E5M1-06, check AC boxes and add a one-line landed note with link to [NEO-81 implementation plan](../plans/NEO-81-implementation-plan.md) (same pattern as E5M1-05).~~ **Done.**
4. ~~**Defeated deny assertion parity** — `TryResolve_WithGuardOnDefeatedTarget_ShouldDenyWithTargetDefeated` omits `TargetRemainingHp` / `TargetDefeated` envelope assertions that the pulse defeated test covers; add `Assert.Equal(0, result.TargetRemainingHp)` and `Assert.False(result.TargetDefeated)` for consistency with the plans deny payload contract.~~ **Done.**
## Nits
- ~~Nit: `TryResolve_WithFourPulses_ShouldDefeatTargetOnFourthResolve` runs four resolves in **Act**; pulses 13 are setup for the fourth outcome — move them to **Arrange** for stricter AAA (same note as NEO-80 four-pulse pattern).~~ **Done.**
- ~~Nit: `CreatePrototypeRegistry` includes **`prototype_dash`** but no dash resolve test; guard covers the zero-damage path — optional dash smoke for catalog completeness.~~ **Done** — added `TryResolve_WithDashOnLiveTarget_ShouldSucceedWithZeroDamage`.
- Nit: `TryApplyDamage` false branch maps to **`unknown_target`** — correct for current store contract (unknown/empty/negative damage), but if store semantics expand (e.g. internal failure), NEO-82 cast wiring may want a distinct ops-level code; defer until cast integration. **Deferred** — no change in NEO-81.
## Verification
```bash
cd /home/don/neon-sprawl/server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~CombatOperationsTests"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
**CombatOperationsTests:** 10/10 passed (local run after follow-up).
**Full suite:** 389 passed, 1 failed — `TargetingApiTests.GetTarget_ShouldReturnNone_WhenNoLock` (unrelated to this branch; investigate separately if CI is green on `main`).
Manual: **N/A** — no HTTP or client surface in this story.

View File

@ -0,0 +1,267 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class CombatOperationsTests
{
private static AbilityDefinitionRegistry CreatePrototypeRegistry()
{
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeBurst,
"Prototype Burst",
40,
5.0,
"attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash",
0,
4.0,
"movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard",
0,
6.0,
"utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1);
return new AbilityDefinitionRegistry(catalog);
}
[Fact]
public void TryResolve_WithPulseOnFreshAlpha_ShouldDealCatalogDamage()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(25, result.DamageDealt);
Assert.Equal(75, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
}
[Fact]
public void TryResolve_WithFourPulses_ShouldDefeatTargetOnFourthResolve()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
for (var i = 0; i < 3; i++)
{
_ = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
}
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Equal(0, result.TargetRemainingHp);
Assert.True(result.TargetDefeated);
}
[Fact]
public void TryResolve_OnDefeatedTarget_ShouldDenyWithTargetDefeated()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
for (var i = 0; i < 4; i++)
{
_ = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
}
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.TargetDefeated, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Equal(0, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
Assert.Equal(0, snapshot.CurrentHp);
Assert.True(snapshot.Defeated);
}
[Fact]
public void TryResolve_WithGuardOnLiveTarget_ShouldSucceedWithZeroDamage()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeGuard,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolve_WithGuardOnDefeatedTarget_ShouldDenyWithTargetDefeated()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeGuard,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.TargetDefeated, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Equal(0, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
}
[Fact]
public void TryResolve_WithDashOnLiveTarget_ShouldSucceedWithZeroDamage()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeDash,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
}
[Fact]
public void TryResolve_WithUnknownAbility_ShouldDenyWithUnknownAbility()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
"not_a_real_ability",
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.UnknownAbility, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Null(result.TargetRemainingHp);
}
[Fact]
public void TryResolve_WithUnknownTarget_ShouldDenyWithUnknownTarget()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
"not_a_target",
registry,
store);
// Assert
Assert.False(result.Success);
Assert.Equal(CombatReasonCodes.UnknownTarget, result.ReasonCode);
Assert.Equal(0, result.DamageDealt);
Assert.Null(result.TargetRemainingHp);
}
[Fact]
public void TryResolve_WithBurstOnFreshAlpha_ShouldDealFortyDamage()
{
// Arrange
var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeBurst,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Equal(40, result.DamageDealt);
Assert.Equal(60, result.TargetRemainingHp);
Assert.False(result.TargetDefeated);
}
[Fact]
public async Task Host_ShouldResolveCombatDependenciesFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var registry = factory.Services.GetRequiredService<IAbilityDefinitionRegistry>();
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
// Act
var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId,
registry,
store);
// Assert
Assert.True(result.Success);
Assert.Equal(25, result.DamageDealt);
Assert.Equal(75, result.TargetRemainingHp);
}
}

View File

@ -0,0 +1,65 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Orchestrates deterministic combat resolution: ability catalog damage + prototype target HP (NEO-81).
/// Cast HTTP wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-82).
/// </summary>
public static class CombatOperations
{
/// <summary>
/// Resolves one ability against one prototype combat target using catalog damage and the health store.
/// Defeated targets deny before damage application; zero-damage abilities succeed without mutating HP.
/// </summary>
public static CombatResult TryResolve(
string abilityId,
string targetId,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore healthStore)
{
if (!abilityRegistry.TryNormalizeKnown(abilityId, out var normalizedAbility) ||
!abilityRegistry.TryGetDefinition(normalizedAbility, out var definition))
{
return Deny(CombatReasonCodes.UnknownAbility);
}
if (!healthStore.TryGet(targetId, out var snapshot))
{
return Deny(CombatReasonCodes.UnknownTarget);
}
if (snapshot.Defeated)
{
return Deny(CombatReasonCodes.TargetDefeated, snapshot.CurrentHp);
}
if (definition.BaseDamage == 0)
{
return new CombatResult(
Success: true,
ReasonCode: null,
DamageDealt: 0,
TargetRemainingHp: snapshot.CurrentHp,
TargetDefeated: snapshot.Defeated);
}
if (!healthStore.TryApplyDamage(targetId, definition.BaseDamage, out var after))
{
return Deny(CombatReasonCodes.UnknownTarget);
}
return new CombatResult(
Success: true,
ReasonCode: null,
DamageDealt: definition.BaseDamage,
TargetRemainingHp: after.CurrentHp,
TargetDefeated: after.Defeated);
}
private static CombatResult Deny(string reasonCode, int? targetRemainingHp = null) =>
new(
Success: false,
ReasonCode: reasonCode,
DamageDealt: 0,
TargetRemainingHp: targetRemainingHp,
TargetDefeated: false);
}

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Targeting;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Stable deny reason codes for combat operations (NEO-81).</summary>
public static class CombatReasonCodes
{
/// <summary>Target HP is already zero before resolve; no damage applied.</summary>
public const string TargetDefeated = "target_defeated";
/// <summary>Passthrough from <see cref="AbilityCastApi.ReasonUnknownAbility"/>.</summary>
public const string UnknownAbility = AbilityCastApi.ReasonUnknownAbility;
/// <summary>Passthrough from <see cref="TargetingApi.ReasonUnknownTarget"/>.</summary>
public const string UnknownTarget = TargetingApi.ReasonUnknownTarget;
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Server-internal combat resolution envelope (NEO-81); promoted to wire JSON via cast response extension (NEO-82).
/// </summary>
/// <param name="DamageDealt">Catalog damage applied on success; zero on deny.</param>
/// <param name="TargetRemainingHp">Authoritative HP after success; optional on deny (e.g. zero on <c>target_defeated</c>).</param>
/// <param name="TargetDefeated"><c>true</c> when post-resolve HP is zero on success; <c>false</c> on deny.</param>
public readonly record struct CombatResult(
bool Success,
string? ReasonCode,
int DamageDealt,
int? TargetRemainingHp,
bool TargetDefeated);

View File

@ -114,6 +114,20 @@ Server-owned HP for prototype combat dummies lives in **`Game/Combat/`** as **`I
Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md).
## Combat engine (NEO-81)
**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. Cast HTTP wiring and wire **`CombatResolution`** fields are **NEO-82**.
| Reason code | When |
|-------------|------|
| **`unknown_ability`** | **`abilityId`** failed registry normalization (empty/garbage/off-list). |
| **`unknown_target`** | **`targetId`** not in **`PrototypeTargetRegistry`** (health store gate). |
| **`target_defeated`** | Target HP is already **0** before resolve; no damage applied (includes zero-damage utility). |
**`CombatResult`** fields: **`success`**, **`reasonCode`** (`null` on success), **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**. On **`target_defeated`** deny, **`targetRemainingHp`** is **0** and **`targetDefeated`** on the envelope is **`false`** — use **`reasonCode`** for deny semantics.
Plan: [NEO-81 implementation plan](../../docs/plans/NEO-81-implementation-plan.md); cast wiring: [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82).
## Recipe definitions (NEO-68)
**`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.