diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index a5a0c6b..64503cd 100644 --- a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md +++ b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M3 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **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) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | +| **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) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **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 @@ -88,6 +88,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin **Progress + completion stores landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** (in-memory) + **`EncounterProgressOperations`** — activation, order-independent defeat tracking, idempotent completion flag; combat/HTTP wiring deferred to NEO-106/108. Plan: [NEO-104 implementation plan](../../plans/NEO-104-implementation-plan.md); [server README — Encounter progress](../../../server/README.md#encounter-progress--completion-stores-neo-104). +**Completion + inventory grants landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations.TryCompleteAndGrant`** + **`EncounterCompleteEvent`** result payload — reward-table fixed grants via **`PlayerInventoryOperations`**, fail-closed inventory deny, idempotent completion mark; event persistence deferred to NEO-107. Plan: [NEO-105 implementation plan](../../plans/NEO-105-implementation-plan.md); [server README — Encounter completion](../../../server/README.md#encounter-completion--inventory-grants-neo-105). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 37a4f73..eae5ee8 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -58,7 +58,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | 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 | Ready | **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 init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../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, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../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 — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **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); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../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; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../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 replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../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 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../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/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../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/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** 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 ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | -| E5.M3 | In Progress | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). Epic 5 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). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **Grants + combat/HTTP wire (NEO-105+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | +| E5.M3 | In Progress | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). Epic 5 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). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **Combat/HTTP wire (NEO-106+) outstanding.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index a176cb3..ee95acf 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **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)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **Grants + combat/HTTP wire (NEO-105+) outstanding.** +**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)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **Combat/HTTP wire (NEO-106+) outstanding.** ### Epic 6 — PvP Security diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index bc77183..2449123 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -211,9 +211,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **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. +- [x] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically. +- [x] Second completion does not duplicate grants. +- [x] Inventory deny fails closed without marking complete. + +**Landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** `EncounterCompletionOperations.TryCompleteAndGrant` + `EncounterCompleteEvent` result payload; AAA unit tests; plan [NEO-105-implementation-plan.md](NEO-105-implementation-plan.md). **Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels. diff --git a/docs/plans/NEO-105-implementation-plan.md b/docs/plans/NEO-105-implementation-plan.md new file mode 100644 index 0000000..e9e5910 --- /dev/null +++ b/docs/plans/NEO-105-implementation-plan.md @@ -0,0 +1,131 @@ +# NEO-105 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-105 | +| **Title** | E5M3-06: EncounterCompletionOperations + inventory reward apply | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-105/e5m3-06-encountercompletionoperations-inventory-reward-apply | +| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-06** | +| **Branch** | `NEO-105-encounter-completion-inventory-reward-apply` | +| **Precursor** | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) — `IEncounterProgressStore` + `IEncounterCompletionStore` + `EncounterProgressOperations` (**landed on `main`**) | +| **Pattern** | [NEO-62](NEO-62-implementation-plan.md) / `GatherOperations` — static ops + reason codes + grant summary; [NEO-69](NEO-69-implementation-plan.md) / `CraftOperations` — simulate-all pre-flight + compensating rollback on partial apply failure | +| **Blocks** | [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) — combat defeat → progress/completion wiring; [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) — `EncounterCompleteEvent` persistence + E7.M2 hook stub | +| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels (via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) HTTP read) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **EncounterCompleteEvent scope** | Define event type on NEO-105 vs defer entirely to NEO-107? | **Define `EncounterCompleteEvent` + return in `EncounterCompletionResult` on success** — E5M3-06 goal calls for internal completion payload; **NEO-107** persists the record and adds E7.M2 hook stub (E5M3-09). | **Adopted** — result payload only; no persistence | +| **Grant transaction** | Pre-flight vs apply-then-hope? | **Simulate all `fixedGrants` on inventory snapshot, then apply sequentially with compensating rollback** — mirror `CraftOperations`; never `TryMarkCompleted` unless every grant applied. | **Adopted** (repo precedent; Linear AC “inventory deny fails closed”) | +| **Completion gate** | Who checks all-defeated? | **`TryCompleteAndGrant` requires `EncounterProgressOperations.IsAllRequiredTargetsDefeated`** + not already completed — NEO-106 calls after last defeat mark. | **Adopted** (backlog + NEO-104 split) | +| **Combat / HTTP wiring** | Wire `AbilityCastApi` or HTTP on this story? | **Defer** — NEO-106 / NEO-108; NEO-105 ships operations + unit tests only. | **Adopted** (backlog out-of-scope) | +| **Bruno** | Add encounter-completion Bruno folder? | **Defer to NEO-108** — no player HTTP route yet; backlog marks Bruno optional here. | **Adopted** (backlog) | + +## Goal, scope, and out-of-scope + +**Goal:** `EncounterCompletionOperations.TryCompleteAndGrant` applies **`RewardTable.fixedGrants`** via **`PlayerInventoryOperations`** exactly once per player+encounter, marks the completion store only when all grants succeed, and returns an internal **`EncounterCompleteEvent`** payload for downstream routers (persistence deferred). + +**In scope (from Linear + [E5M3-06](E5M3-prototype-backlog.md#e5m3-06--encountercompletionoperations--inventory-reward-apply)):** + +- **`EncounterCompletionOperations.TryCompleteAndGrant`** — resolve encounter → reward table → pre-flight simulate → apply fixed grants → `TryMarkCompleted` with `TimeProvider.GetUtcNow()`. +- **`EncounterCompletionResult`**, **`EncounterCompletionReasonCodes`**, **`EncounterGrantApplied`**, **`EncounterCompleteEvent`** (internal record; not persisted). +- Structured deny codes: `already_completed`, `not_ready`, `unknown_encounter`, `unknown_reward_table`, `inventory_full`, `inventory_store_missing`, passthrough `invalid_item` / `invalid_quantity`. +- Compensating inventory rollback when a later grant fails after earlier grants applied, or when `TryMarkCompleted` returns false after grants (race/idempotent replay). +- Unit tests (AAA) with `InMemoryWebApplicationFactory`: first completion grants frozen prototype loot once; second completion no duplicate; bag-full deny without completion flag. +- `server/README.md` encounter completion section (brief). + +**Out of scope (from Linear + backlog):** + +- Combat defeat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)). +- **`EncounterCompleteEvent` store / E7.M2 quest-credit hook** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)). +- HTTP **`GET /game/players/{id}/encounter-progress`** ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)). +- Godot HUD — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**. +- Postgres persistence, Bruno, `docs/manual-qa/NEO-105.md`. + +## Acceptance criteria checklist + +- [x] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically. +- [x] Second completion does not duplicate grants. +- [x] Inventory deny fails closed without marking complete. + +## Implementation reconciliation (shipped) + +- **Operations:** `EncounterCompletionOperations.TryCompleteAndGrant` — pre-flight simulate, sequential grant apply with compensating rollback, idempotent `TryMarkCompleted`. +- **Types:** `EncounterCompletionResult`, `EncounterCompletionReasonCodes`, `EncounterGrantApplied`, `EncounterCompleteEvent` (result payload only; NEO-107 persists). +- **Tests:** `EncounterCompletionOperationsTests` — grant once, `already_completed` replay, `not_ready`, `inventory_full` fail-closed, apply-phase rollback (`InventoryStoreFullAfterFirstGrant`), mark-fail rollback. +- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated. + +## Technical approach + +1. **`EncounterCompletionReasonCodes`** + - Domain-specific: `already_completed`, `not_ready`, `unknown_encounter`, `unknown_reward_table`, `inventory_store_missing`. + - Passthrough: `inventory_full`, `invalid_item`, `invalid_quantity` from **`PlayerInventoryReasonCodes`**. + +2. **`EncounterGrantApplied`** — readonly record `(ItemId, Quantity)` mirroring **`GatherGrantApplied`**. + +3. **`EncounterCompleteEvent`** — readonly record: `PlayerId`, `EncounterId`, `RewardTableId`, `IReadOnlyList GrantedItems`, `DateTimeOffset CompletedAt`, `IdempotencyKey` (stable string e.g. `{playerId}:{encounterId}` normalized). Returned on success only; **NEO-107** will persist/emit. + +4. **`EncounterCompletionResult`** — `(Success, ReasonCode?, GrantsApplied, EncounterCompleteEvent? CompleteEvent, DateTimeOffset? CompletedAt)`. + +5. **`EncounterCompletionOperations.TryCompleteAndGrant`** + - Normalize **`encounterId`** via **`IEncounterDefinitionRegistry.TryNormalizeKnown`**; load **`EncounterDefRow`**. + - Early deny **`already_completed`** if **`IEncounterCompletionStore.IsCompleted`**. + - Early deny **`not_ready`** if **`!EncounterProgressOperations.IsAllRequiredTargetsDefeated`**. + - Resolve **`rewardTableId`** → **`IRewardTableDefinitionRegistry.TryGetDefinition`**; deny **`unknown_reward_table`** if missing. + - Load **`FixedGrants`** (prototype: scrap ×10, token ×1). + - **Pre-flight:** clone inventory snapshot; **`PlayerInventoryOperations.TrySimulateAddStack`** for each grant in catalog order. + - **Apply:** foreach grant **`TryAddStack`**; on deny/store-missing, **`CompensatingRemoveGrants`** for prior applied rows and return deny **without** marking complete. + - **`TryMarkCompleted(playerId, encounterId, timeProvider.GetUtcNow())`**; if `false`, compensating remove all grants and return **`already_completed`**. + - Success: build **`EncounterCompleteEvent`**, return grants + event + `CompletedAt`. + - Comment-only hook site: `// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent` (NEO-107 expands). + +6. **No new DI registration** — static operations like **`GatherOperations`**; callers inject stores/registries from existing **`AddEncounterAndRewardCatalogs`** + inventory DI. + +### Prototype completion flow (frozen) + +| Step | Expected | +|------|----------| +| Setup | Activate + defeat all three NPCs → `IsAllRequiredTargetsDefeated` true | +| First grant | `TryCompleteAndGrant` → success; bag +10 scrap, +1 token; completion store marked | +| Replay | Second `TryCompleteAndGrant` → deny `already_completed`; inventory unchanged | +| Bag full | Fill bag slots → `TryCompleteAndGrant` → deny `inventory_full`; `IsCompleted` false | + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs` | Stable deny reason constants. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs` | Successful grant row in result/event. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs` | Internal completion payload (returned, not persisted on NEO-105). | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs` | Server-internal completion resolution envelope. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | `TryCompleteAndGrant` orchestration. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | AAA unit tests for grant-once, idempotent deny, inventory-full fail-closed. | +| `docs/plans/NEO-105-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/README.md` | Document `EncounterCompletionOperations`, reason codes, and deferrals to NEO-106/107/108. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — completion + inventory grant ops (NEO-105). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-105 completion grants when landed. | +| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-105 completion ops landed (when shipped). | +| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-06 AC when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `EncounterCompletionOperationsTests.cs` | **Arrange:** `InMemoryWebApplicationFactory` + activate/defeat-all helper for `prototype_combat_pocket`. **Act/Assert:** first `TryCompleteAndGrant` success with scrap ×10 + token ×1 in inventory and `CompleteEvent` populated; second call `already_completed` with unchanged quantities; `not_ready` when only 2/3 defeated; bag-full deny `inventory_full` with `IsCompleted` false and zero grant delta; apply-phase rollback via `InventoryStoreFullAfterFirstGrant` when second grant fails after first succeeds; mark-fail rollback when `TryMarkCompleted` returns false. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Grant order** | Apply in **`FixedGrants` catalog order** (scrap then token); rollback in reverse on failure. | **adopted** | +| **Mark-complete race** | If `TryMarkCompleted` false after grants, remove grants and return `already_completed` — treat as lost race to another completer. | **adopted** | +| **Event persistence** | Deferred to **NEO-107** — NEO-105 only returns payload in result. | **adopted** | +| **Postgres follow-on** | Defer — completion store Postgres migration is a future session-persistence issue. | **deferred** | diff --git a/docs/reviews/2026-05-31-NEO-105.md b/docs/reviews/2026-05-31-NEO-105.md new file mode 100644 index 0000000..827e1e6 --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-105.md @@ -0,0 +1,60 @@ +# Code review — NEO-105 (E5M3-06) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-105-encounter-completion-inventory-reward-apply` vs `719a14f` (merge-base on `main`) — commits `b5306aa` … `009fedf` +**Base:** `719a14f` (main at branch point, post NEO-104 merge) + +**Follow-up:** Review suggestions and nits addressed in commit after `009fedf`. + +## Verdict + +**Approve with nits** + +## Summary + +NEO-105 adds **`EncounterCompletionOperations.TryCompleteAndGrant`**, which resolves an encounter and reward table, pre-flights inventory via **`PlayerInventoryOperations.TrySimulateAddStack`**, applies **`fixedGrants`** sequentially with compensating rollback on partial failure or failed **`TryMarkCompleted`**, and returns an internal **`EncounterCompleteEvent`** payload on success (persistence deferred to NEO-107). Supporting types mirror established patterns (`GatherOperations` / `CraftOperations`): **`EncounterCompletionResult`**, **`EncounterCompletionReasonCodes`**, **`EncounterGrantApplied`**. Six AAA unit tests cover grant-once, idempotent replay, `not_ready`, bag-full fail-closed, mark-fail rollback, and unknown encounter. Docs (plan, backlog, E5.M3 snapshot, alignment register, module register, `server/README.md`) are updated. Combat wiring and HTTP read remain correctly deferred to NEO-106 / NEO-108; client counterpart NEO-110 is out of scope. Risk is low: static ops with no new DI or player-visible surface until NEO-106. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-105-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; combat/HTTP/client deferred. | +| `docs/plans/E5M3-prototype-backlog.md` (E5M3-06) | **Matches** — acceptance criteria checked; landed note cites plan. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Status marks **E5M3-06 / NEO-105** landed; implementation snapshot bullet added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-105** completion grants; NEO-106+ combat/HTTP still outstanding. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-105** completion ops landed. | +| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110** via NEO-108; no prototype slice completion claim. | +| `server/README.md` | **Matches** — encounter completion section documents ops, reason codes, frozen grants, and deferrals. | + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Apply-phase partial rollback test** — The plan test table mentions compensating rollback when the bag cannot fit both grants, but **`FillBag`** fills every slot so pre-flight denies before any apply; the mark-fail test covers rollback after both grants succeed. Consider an AAA test where pre-flight passes but the second **`TryAddStack`** fails during apply (e.g. stub inventory store that allows simulate/add for the first grant only) to exercise **`CompensatingRemoveGrants`** on the apply path, not only the mark-complete race path.~~ **Done.** `TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails` uses **`InventoryStoreFullAfterFirstGrant`** to fill the bag after the first apply so pre-flight still passes. + +2. ~~**Denial telemetry hook comment** — **`EncounterProgressOperations`** documents an E9.M1 hook on activation; **`CraftOperations`** / **`GatherOperations`** comment deny sites. **`EncounterCompletionOperations`** only has the E7.M2 success TODO. A brief `// TODO(E9.M1): catalog emit — encounter_complete_denied` (or similar) on **`Deny`** would align with NEO-109 without adding runtime code.~~ **Done.** `encounter_complete_denied` hook comment added on **`Deny`**. + +3. ~~**`unknown_reward_table` for empty `FixedGrants`** — Empty grants return **`unknown_reward_table`** (also documented in README). Prototype catalog gates prevent this at startup; if a future table legitimately has zero fixed grants, a dedicated reason code may be clearer. Fine for prototype; note for catalog expansion.~~ **Addressed.** Inline comment on empty-grants branch; dedicated reason deferred until zero-grant tables ship. + +## Nits + +- ~~Nit: **`MapInventoryReason`** default branch maps unrecognized inventory reason codes to **`inventory_full`**. Safe given startup validation; passthrough of the raw code (or a dedicated **`inventory_denied`**) would aid debugging if invalid catalog rows slip through in tests.~~ **Done.** Unrecognized codes passthrough; **`null`** still maps to **`inventory_full`**. + +- ~~Nit: **`TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice`** — first successful completion is correctly in **Arrange**; consider asserting **`first.ReasonCode`** is null and **`second.GrantsApplied`** is empty in one block for symmetry with the success test (already partially covered).~~ **Done.** Assert **`first.ReasonCode`** null and **`first.GrantsApplied.Count`** == 2 alongside second-call denies. + +## Verification + +```bash +# NEO-105-focused (6 tests — passed locally) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterCompletionOperations" + +# Broader encounter slice (62 tests — passed locally; one flaky failure observed on first full run, passed on retry) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" + +# CI parity +python3 scripts/validate_content.py +``` + +**Local run:** 6/6 `EncounterCompletionOperations` tests passed; 62/62 `Encounters` filter tests passed on retry. diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs new file mode 100644 index 0000000..70a847a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -0,0 +1,420 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public sealed class EncounterCompletionOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string EncounterId = "prototype_combat_pocket"; + private const string MeleeNpc = "prototype_npc_melee"; + private const string RangedNpc = "prototype_npc_ranged"; + private const string EliteNpc = "prototype_npc_elite"; + + [Fact] + public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + var timeProvider = TimeProvider.System; + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + timeProvider); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Equal(2, result.GrantsApplied.Count); + Assert.Contains(result.GrantsApplied, g => g.ItemId == "scrap_metal_bulk" && g.Quantity == 10); + Assert.Contains(result.GrantsApplied, g => g.ItemId == "contract_handoff_token" && g.Quantity == 1); + Assert.NotNull(result.CompleteEvent); + Assert.Equal(PlayerId, result.CompleteEvent!.Value.PlayerId); + Assert.Equal(EncounterId, result.CompleteEvent.Value.EncounterId); + Assert.Equal("prototype_combat_pocket_clear", result.CompleteEvent.Value.RewardTableId); + Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey); + Assert.NotNull(result.CompletedAt); + Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + var timeProvider = TimeProvider.System; + var first = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + timeProvider); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst); + + // Act + var second = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + timeProvider); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterSecond); + Assert.True(first.Success); + Assert.Null(first.ReasonCode); + Assert.Equal(2, first.GrantsApplied.Count); + Assert.False(second.Success); + Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, second.ReasonCode); + Assert.Empty(second.GrantsApplied); + Assert.Null(second.CompleteEvent); + Assert.Equal(10, CountBagItem(afterSecond!, "scrap_metal_bulk")); + Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token")); + Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk")); + Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyNotReady_WhenOnlyTwoTargetsDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + _ = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + RangedNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyInventoryFull_WhenBagHasNoFreeSlots() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + FillBag(deps); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk")); + Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldRollbackGrants_WhenCompletionMarkFails() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + var failingCompletionStore = new CompletionStoreDeniesMark(deps.CompletionStore); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + failingCompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, result.ReasonCode); + Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!)); + Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk")); + Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + DefeatAllRequiredTargets(deps); + var inventoryStore = new InventoryStoreFullAfterFirstGrant(deps.InventoryStore, deps.ItemRegistry); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + EncounterId, + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + inventoryStore, + TimeProvider.System); + + // Assert + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode); + Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId)); + Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk")); + Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token")); + } + + [Fact] + public async Task TryCompleteAndGrant_ShouldDenyUnknownEncounter_WhenIdIsInvalid() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + var result = EncounterCompletionOperations.TryCompleteAndGrant( + PlayerId, + "unknown_encounter", + deps.EncounterRegistry, + deps.RewardTableRegistry, + deps.ProgressStore, + deps.CompletionStore, + deps.ItemRegistry, + deps.InventoryStore, + TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode); + } + + private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps) + { + _ = EncounterProgressOperations.TryActivateOnFirstEngagement( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + MeleeNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + RangedNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + _ = EncounterProgressOperations.TryMarkTargetDefeated( + PlayerId, + EliteNpc, + deps.EncounterRegistry, + deps.ProgressStore, + deps.CompletionStore); + } + + private static void FillBag(EncounterCompletionTestDependencies deps) + { + for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) + { + var add = PlayerInventoryOperations.TryAddStack( + PlayerId, + "survey_drone_kit", + quantity: 1, + deps.ItemRegistry, + deps.InventoryStore); + Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind); + } + } + + private static EncounterCompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new EncounterCompletionTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) + { + var total = 0; + foreach (var slot in snapshot.BagSlots) + { + if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal)) + { + total += slot.Quantity; + } + } + + return total; + } + + private static int TotalBagQuantity(PlayerInventorySnapshot snapshot) + { + var total = 0; + foreach (var slot in snapshot.BagSlots) + { + if (!slot.IsEmpty) + { + total += slot.Quantity; + } + } + + return total; + } + + private sealed record EncounterCompletionTestDependencies( + IEncounterDefinitionRegistry EncounterRegistry, + IRewardTableDefinitionRegistry RewardTableRegistry, + IEncounterProgressStore ProgressStore, + IEncounterCompletionStore CompletionStore, + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore); + + private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore + { + public bool IsCompleted(string playerId, string encounterId) => + inner.IsCompleted(playerId, encounterId); + + public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => false; + + public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) => + inner.TryGetCompletedAt(playerId, encounterId, out completedAt); + } + + /// + /// After the first successful inventory mutation, fills remaining bag slots so the next apply fails + /// while pre-flight simulate (on an empty snapshot) still passes. + /// + private sealed class InventoryStoreFullAfterFirstGrant( + IPlayerInventoryStore inner, + IItemDefinitionRegistry itemRegistry) : IPlayerInventoryStore + { + private bool _firstMutationApplied; + + public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot) => + inner.TryGetSnapshot(playerId, out snapshot); + + public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) => + inner.TryReplaceSnapshot(playerId, snapshot); + + public bool TryMutateSnapshot( + string playerId, + Func mutator, + out PlayerInventorySnapshot result) + { + if (!inner.TryMutateSnapshot(playerId, mutator, out result)) + { + return false; + } + + if (!_firstMutationApplied) + { + _firstMutationApplied = true; + FillRemainingBagSlots(playerId); + inner.TryGetSnapshot(playerId, out result); + } + + return true; + } + + private void FillRemainingBagSlots(string playerId) + { + for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) + { + var add = PlayerInventoryOperations.TryAddStack( + playerId, + "survey_drone_kit", + quantity: 1, + itemRegistry, + inner); + if (add.Kind != PlayerInventoryMutationKind.Applied) + { + break; + } + } + } + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs index 903eecd..ad7e117 100644 --- a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs +++ b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs @@ -56,7 +56,7 @@ public static class CraftOperations } } - var simulated = CloneSnapshot(snapshot); + var simulated = snapshot.Clone(); foreach (var input in scaledInputs) { if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry)) @@ -176,13 +176,6 @@ public static class CraftOperations return scaled; } - private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) => - new() - { - BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots), - EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots), - }; - private static void CompensatingRestoreInputs( string playerId, IReadOnlyList inputs, diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs new file mode 100644 index 0000000..e3a18ac --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Internal completion payload returned on successful encounter grant (NEO-105). +/// Persistence and E7.M2 quest-credit routing land in NEO-107. +/// +public readonly record struct EncounterCompleteEvent( + string PlayerId, + string EncounterId, + string RewardTableId, + IReadOnlyList GrantedItems, + DateTimeOffset CompletedAt, + string IdempotencyKey); diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs new file mode 100644 index 0000000..692b085 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -0,0 +1,173 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Applies encounter reward-table grants and marks completion idempotently (NEO-105). +/// Combat defeat wiring: (NEO-106). +/// +public static class EncounterCompletionOperations +{ + private static readonly EncounterGrantApplied[] EmptyGrants = []; + + /// + /// When all required targets are defeated and the encounter is not yet completed, applies + /// 's reward-table fixed grants then marks completion. + /// + public static EncounterCompletionResult TryCompleteAndGrant( + string playerId, + string encounterId, + IEncounterDefinitionRegistry encounterRegistry, + IRewardTableDefinitionRegistry rewardTableRegistry, + IEncounterProgressStore progressStore, + IEncounterCompletionStore completionStore, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + TimeProvider timeProvider) + { + if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) || + !encounterRegistry.TryGetDefinition(normalizedEncounterId, out var encounterDefinition)) + { + return Deny(EncounterCompletionReasonCodes.UnknownEncounter); + } + + if (completionStore.IsCompleted(playerId, normalizedEncounterId)) + { + return Deny(EncounterCompletionReasonCodes.AlreadyCompleted); + } + + if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated( + playerId, + normalizedEncounterId, + encounterRegistry, + progressStore)) + { + return Deny(EncounterCompletionReasonCodes.NotReady); + } + + if (!rewardTableRegistry.TryGetDefinition(encounterDefinition.RewardTableId, out var rewardTable)) + { + return Deny(EncounterCompletionReasonCodes.UnknownRewardTable); + } + + var grants = rewardTable.FixedGrants; + if (grants.Count == 0) + { + // Prototype catalog gates prevent empty tables at startup; dedicated reason if zero-grant tables ship later. + return Deny(EncounterCompletionReasonCodes.UnknownRewardTable); + } + + if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot)) + { + return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing); + } + + var simulated = snapshot.Clone(); + foreach (var grant in grants) + { + if (!PlayerInventoryOperations.TrySimulateAddStack( + ref simulated, + grant.ItemId, + grant.Quantity, + itemRegistry)) + { + return Deny(EncounterCompletionReasonCodes.InventoryFull); + } + } + + var applied = new List(grants.Count); + foreach (var grant in grants) + { + var addOutcome = PlayerInventoryOperations.TryAddStack( + playerId, + grant.ItemId, + grant.Quantity, + itemRegistry, + inventoryStore); + + if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore); + return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing); + } + + if (addOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore); + return Deny(MapInventoryReason(addOutcome.ReasonCode)); + } + + applied.Add(new EncounterGrantApplied(grant.ItemId, grant.Quantity)); + } + + var completedAt = timeProvider.GetUtcNow(); + if (!completionStore.TryMarkCompleted(playerId, normalizedEncounterId, completedAt)) + { + CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore); + return Deny(EncounterCompletionReasonCodes.AlreadyCompleted); + } + + // TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook). + + var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId); + var completeEvent = new EncounterCompleteEvent( + normalizedPlayerId, + normalizedEncounterId, + rewardTable.Id, + applied, + completedAt, + MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId)); + + return new EncounterCompletionResult( + Success: true, + ReasonCode: null, + GrantsApplied: applied, + CompleteEvent: completeEvent, + CompletedAt: completedAt); + } + + private static string MapInventoryReason(string? reasonCode) => + reasonCode switch + { + PlayerInventoryReasonCodes.InventoryFull => EncounterCompletionReasonCodes.InventoryFull, + PlayerInventoryReasonCodes.InvalidItem => EncounterCompletionReasonCodes.InvalidItem, + PlayerInventoryReasonCodes.InvalidQuantity => EncounterCompletionReasonCodes.InvalidQuantity, + null => EncounterCompletionReasonCodes.InventoryFull, + _ => reasonCode, + }; + + private static string MakeIdempotencyKey(string normalizedPlayerId, string normalizedEncounterId) => + $"{normalizedPlayerId}:{normalizedEncounterId}"; + + private static void CompensatingRemoveGrants( + string playerId, + IReadOnlyList grants, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore) + { + for (var i = grants.Count - 1; i >= 0; i--) + { + var grant = grants[i]; + _ = PlayerInventoryOperations.TryRemoveStack( + playerId, + grant.ItemId, + grant.Quantity, + itemRegistry, + inventoryStore); + } + } + + private static EncounterCompletionResult Deny(string reasonCode) + { + // --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_complete_denied` --- + // TODO(E9.M1): catalog emit — structured deny with reasonCode (EncounterCompletionReasonCodes). + // Planned payload: playerId, encounterId, reasonCode. No ingest or ILogger here (comments-only). + + return new EncounterCompletionResult( + Success: false, + ReasonCode: reasonCode, + GrantsApplied: EmptyGrants, + CompleteEvent: null, + CompletedAt: null); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs new file mode 100644 index 0000000..bd534ac --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs @@ -0,0 +1,26 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Stable deny reason codes for encounter completion operations (NEO-105). +public static class EncounterCompletionReasonCodes +{ + public const string AlreadyCompleted = "already_completed"; + + public const string NotReady = "not_ready"; + + public const string UnknownEncounter = "unknown_encounter"; + + public const string UnknownRewardTable = "unknown_reward_table"; + + public const string InventoryStoreMissing = "inventory_store_missing"; + + /// Passthrough from . + public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull; + + /// Passthrough from . + public const string InvalidItem = PlayerInventoryReasonCodes.InvalidItem; + + /// Passthrough from . + public const string InvalidQuantity = PlayerInventoryReasonCodes.InvalidQuantity; +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs new file mode 100644 index 0000000..a8c66f5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Server-internal encounter completion resolution envelope (NEO-105). +/// Combat wiring promotes via NEO-106; HTTP DTOs via NEO-108. +/// +/// Item grants on success; empty when denied. +/// Completion payload on success; null when denied. +public readonly record struct EncounterCompletionResult( + bool Success, + string? ReasonCode, + IReadOnlyList GrantsApplied, + EncounterCompleteEvent? CompleteEvent, + DateTimeOffset? CompletedAt); diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs new file mode 100644 index 0000000..fc10156 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// One item grant applied on a successful encounter completion (NEO-105). +public readonly record struct EncounterGrantApplied(string ItemId, int Quantity); diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs index 089c9e3..8b16dfb 100644 --- a/server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventorySnapshot.cs @@ -41,6 +41,14 @@ public sealed class PlayerInventorySnapshot return copy; } + /// Deep-copies bag and equipment slot arrays (NEO-69 craft pre-flight, NEO-105 encounter grant pre-flight). + public PlayerInventorySnapshot Clone() => + new() + { + BagSlots = CloneSlots(BagSlots), + EquipmentSlots = CloneSlots(EquipmentSlots), + }; + public InventorySlotState[] GetSlots(InventoryContainerKind container) => container == InventoryContainerKind.Equipment ? EquipmentSlots : BagSlots; diff --git a/server/README.md b/server/README.md index 7a2e3c0..7bc48ce 100644 --- a/server/README.md +++ b/server/README.md @@ -153,6 +153,21 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act Combat cast wiring (**`AbilityCastApi`**) and **`GET /game/players/{id}/encounter-progress`** land in NEO-106 / NEO-108. Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md). +## Encounter completion + inventory grants (NEO-105) + +**`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward table’s **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. Returns internal **`EncounterCompleteEvent`** on success (persistence + E7.M2 hook: NEO-107). + +| Reason code | When | +|-------------|------| +| **`already_completed`** | Encounter already marked complete for this player. | +| **`not_ready`** | Not all **`requiredNpcInstanceIds`** defeated yet. | +| **`unknown_encounter`** | **`encounterId`** not in **`IEncounterDefinitionRegistry`**. | +| **`unknown_reward_table`** | Encounter’s **`rewardTableId`** missing or empty grants. | +| **`inventory_full`** | Pre-flight or apply could not place full grant quantities. | +| **`inventory_store_missing`** | Player inventory store could not read/write. | + +Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** from **`prototype_combat_pocket_clear`**. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Combat wiring lands in NEO-106. Plan: [NEO-105 implementation plan](../../docs/plans/NEO-105-implementation-plan.md). + ## 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`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.