diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index 06e8032..76306ed 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -68,4 +68,6 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r **NEO-107 (E5.M3 producer hook):** On encounter completion grant commit, **`EncounterCompletionOperations.TryCompleteAndGrant`** records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** (idempotency key **`{playerId}:{encounterId}`**). Future E7.M2 **`QuestRewardBundle`** router consumes this record for **`reward_delivery`** quest credit beyond prototype **`contract_handoff_token`** item loot — comment-only hook stub; no runtime router yet. See [NEO-107 implementation plan](../../plans/NEO-107-implementation-plan.md); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). -**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Router apply and quest-state wiring land in NEO-127/NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). +**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Quest-state wiring lands in NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). + +**NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 17aa0be..9962204 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | 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 | Ready | **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). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** 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) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [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 | -| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Wire **`RewardRouterOperations`** on quest complete; extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | +| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Wire router into **`QuestStateOperations.TryMarkComplete`** (NEO-128); extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md index caefe88..339f756 100644 --- a/docs/plans/E7M2-prototype-backlog.md +++ b/docs/plans/E7M2-prototype-backlog.md @@ -160,7 +160,7 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c **In scope** - `RewardRouterOperations.TryDeliverQuestCompletion` in `server/NeonSprawl.Server/Game/Quests/` (or `Game/Rewards/`). -- Item grants via **`PlayerInventoryOperations`**; skill XP via **`MissionRewardSkillXpGrant`**. +- Item grants via **`PlayerInventoryOperations`**; skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (deny envelopes; not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper). - Deny reason codes (inventory full, unknown item/skill, xp deny). - Unit + integration tests (AAA): happy path, inventory deny, idempotent replay via store. @@ -170,8 +170,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c **Acceptance criteria** -- [ ] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record. -- [ ] Skill XP uses **`mission_reward`** source kind only. +- [x] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record. +- [x] Skill XP uses **`mission_reward`** source kind only. **Client counterpart:** none (server engine). diff --git a/docs/plans/NEO-127-implementation-plan.md b/docs/plans/NEO-127-implementation-plan.md new file mode 100644 index 0000000..cb84565 --- /dev/null +++ b/docs/plans/NEO-127-implementation-plan.md @@ -0,0 +1,183 @@ +# NEO-127 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-127 | +| **Title** | E7M2-04: RewardRouterOperations (apply QuestRewardBundle) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-127/e7m2-04-rewardrouteroperations-apply-questrewardbundle | +| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-04** | +| **Branch** | `NEO-127-e7m2-reward-router-operations` | +| **Precursor** | [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) **`Done`** — catalog load + bundle validation · [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) **`Done`** — `IRewardDeliveryStore` + `RewardDeliveryEvent` | +| **Pattern** | [NEO-105](NEO-105-implementation-plan.md) — `EncounterCompletionOperations` simulate-all + sequential apply + compensating rollback; [NEO-107](NEO-107-implementation-plan.md) — store `TryRecord` after grants commit | +| **Blocks** | [NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) (`QuestStateOperations.TryMarkComplete` wiring) | +| **Client counterpart** | none — server engine ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle)) | + +## Kickoff clarifications + +**No clarifications needed.** Linear AC, [E7M2-04 backlog](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle), [E7M2 kickoff decisions](E7M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-105/NEO-126 code settle: + +- **Namespace `Game/Rewards/`** — co-locate router with `IRewardDeliveryStore` (NEO-126); bundle DTOs stay on `QuestDefRow` in `Game/Quests/` (NEO-125). +- **Grant order** — item grants first, skill XP second; compensating **item** rollback on inventory or skill-apply failure (NEO-105 precedent). Prototype bundles have ≤1 skill row; no XP rollback helper exists. +- **Skill XP path** — call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** (not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper) so deny envelopes map to router reason codes. +- **Idempotent replay** — **`IRewardDeliveryStore.TryGet`** before apply; return success with existing **`RewardDeliveryEvent`** without re-granting (backlog “idempotent replay via store”). +- **Empty bundle** — null or both-empty grant lists → success with empty snapshots and **`TryRecord`** (NEO-126 allows empty arrays; CI guarantees all four prototype quests have bundles). +- **Store race** — if grants succeed but **`TryRecord`** returns `false`, **rollback this call's item + skill XP grants** and return the winner's store event (mirror **`EncounterCompletionOperations`** when **`TryMarkCompleted`** loses); do not retain duplicate grants on the loser. + +## Goal, scope, and out-of-scope + +**Goal:** Server-internal apply for quest **`completionRewardBundle`** rows (items + skill XP) with structured outcomes and durable idempotency via **`IRewardDeliveryStore`**. + +**In scope (from Linear + [E7M2-04](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle)):** + +- **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/`. +- Item grants via **`PlayerInventoryOperations`** (simulate-all pre-flight, sequential apply, compensating rollback). +- Skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind. +- **`RewardDeliveryReasonCodes`** + **`RewardDeliveryResult`** (success, deny reason, grant snapshots, **`RewardDeliveryEvent?`**). +- Unit + integration tests (AAA): happy path (skill-only + item+skill bundles), inventory deny, idempotent store replay, apply-phase item rollback, skill deny passthrough. +- `server/README.md` router section. + +**Out of scope (from Linear):** + +- Quest state transition hooks ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) E7M2-05). +- HTTP DTOs / **`completionRewardSummary`** ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06). +- E9.M1 telemetry hook comments ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) E7M2-07). +- Godot client, Bruno (optional on NEO-128). + +## Acceptance criteria checklist + +- [x] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record. +- [x] Skill XP uses **`mission_reward`** source kind only. + +## Implementation reconciliation (shipped) + +- **Operations:** `RewardRouterOperations.TryDeliverQuestCompletion` — item pre-flight + apply, skill XP via `TryApplyGrant` (`mission_reward`), compensating item rollback + per-grant perk tracking + **`TryRevertAppliedSkillGrants`** on **`TryRecord`** race loss, `IRewardDeliveryStore.TryRecord`. +- **Types:** `RewardDeliveryResult`, `RewardDeliveryReasonCodes`. +- **Tests:** 14 AAA tests in `RewardRouterOperationsTests` (…, TryRecord race rollback, concurrent race no double-grant, perk revert on simulated race loss, concurrent level-up winner perk preserved). +- **Docs:** `server/README.md` reward router section; E7.M2 module anchor + alignment register updated. + +## Technical approach + +### 1. Result + reason types (`Game/Rewards/`) + +**`RewardDeliveryReasonCodes`** — stable strings for router denies: + +| Code | When | +|------|------| +| **`already_delivered`** | **`IRewardDeliveryStore.TryGet`** hit before apply (idempotent replay — success path, not a deny). | +| **`inventory_store_missing`** | Inventory snapshot unavailable. | +| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Passthrough from **`PlayerInventoryReasonCodes`** via **`PlayerInventoryOperations`**. | +| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Passthrough from **`SkillProgressionSnapshotApi`** reason constants via **`TryApplyGrant`**. | +| **`progression_store_missing`** | **`SkillProgressionGrantApplyKind.ProgressionStoreMissing`**. | + +**`RewardDeliveryResult`** — readonly record: + +| Field | Role | +|-------|------| +| **`Success`** | Grants committed (includes idempotent replay). | +| **`ReasonCode`** | Set when **`Success`** is false. | +| **`GrantedItems`** | Applied item snapshot (`RewardItemGrantApplied`). | +| **`GrantedSkillXp`** | Applied skill XP snapshot (`RewardSkillXpGrantApplied`). | +| **`DeliveryEvent`** | Populated on success (fresh or replay). | +| **`DeliveredAt`** | From event on success. | + +### 2. `RewardRouterOperations.TryDeliverQuestCompletion` + +Signature (static ops, mirror encounter completion): + +```csharp +public static RewardDeliveryResult TryDeliverQuestCompletion( + string playerId, + string questId, + QuestRewardBundleRow? bundle, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, + TimeProvider timeProvider) +``` + +**Flow:** + +1. **Normalize ids** via **`RewardDeliveryIds`**; fail closed on empty player/quest id (deny with appropriate code or empty reason — match store fail-closed). +2. **Idempotent replay** — if **`deliveryStore.TryGet`**, return **`Success: true`** with existing event and grant snapshots (no inventory/skill mutation). +3. **Resolve grants** — treat null **`bundle`** as empty item + skill lists. +4. **Item pre-flight** — clone inventory snapshot; **`TrySimulateAddStack`** for each **`bundle.ItemGrants`** row in catalog order. +5. **Item apply** — foreach row **`TryAddStack`**; accumulate **`RewardItemGrantApplied`**; on deny/store-missing, return deny **without** skill apply. +6. **Skill XP apply** — foreach **`bundle.SkillXpGrants`** row, **`TryApplyGrant(playerId, skillId, amount, MissionRewardSourceKind, …)`**: + - On **`Denied`**, **`CompensatingRemoveItems`** for applied item rows and return mapped **`ReasonCode`** from **`outcome.Response!.ReasonCode`**. + - On **`ProgressionStoreMissing`**, compensating item rollback + deny. + - On **`Granted`**, append **`RewardSkillXpGrantApplied`**. +7. **Record** — build **`RewardDeliveryEvent`** with **`DeliveredAt = timeProvider.GetUtcNow()`**, **`IdempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(...)`**, grant snapshots. +8. **`deliveryStore.TryRecord`** — if `true`, return fresh success; if `false` (race), **compensating rollback** of this call's item + skill XP grants, then return **`TryGet`** winner event with **`already_delivered`** (or deny if store miss). + +**Compensating item rollback** — private helper mirroring **`EncounterCompletionOperations.CompensatingRemoveGrants`**: reverse-order **`TryRemoveStack`** for applied item rows only (no skill XP undo). + +**Compensating skill progression rollback** — track perks unlocked per skill grant (`CollectPerksUnlockedSince`); on **`TryRecord`** race loss, **`TryRevertAppliedSkillGrants`** reverses each applied skill XP row via negative delta and removes only perks this call unlocked, then **`ReevaluateAfterLevelUp`** at post-rollback levels re-applies path-auto perks the winner's XP still qualifies for (avoids full snapshot restore clobbering a concurrent winner). + +### 3. Skill XP source kind enforcement + +Always pass **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** at apply time — content rows do not carry **`sourceKind`** (NEO-124/NEO-43). Startup catalog gates already require skills to allow **`mission_reward`**; router still maps **`source_kind_not_allowed`** if a deny occurs. + +### 4. Tests (`RewardRouterOperationsTests`) + +Use **`InMemoryWebApplicationFactory`** + resolved DI (same pattern as **`EncounterCompletionOperationsTests`**): + +| Test | Coverage | +|------|----------| +| **`TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly`** | **`prototype_quest_gather_intro`**-shaped bundle → salvage +25; store recorded; replay via second call returns same event without double XP. | +| **`TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle`** | **`survey_drone_kit` ×1** + salvage +50; inventory + progression assertions. | +| **`TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord`** | Fill bag; deny **`inventory_full`**; **`TryGet`** false. | +| **`TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied`** | Item grant succeeds then skill deny (inject invalid amount or use test-only bundle row) → item removed; no store record. | +| **`TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered`** | Pre-seed **`IRewardDeliveryStore`**; call router → success, no inventory/XP change. | +| **`TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind`** | Assert grant succeeds for catalog skill; optional negative with skill lacking allowlist (unit-level bundle row). | + +Optional host smoke: resolve **`RewardRouterOperations`** is static — assert factory provides all injected stores/registries used in one happy-path call. + +### 5. Docs + +- **`server/README.md`** — new **Reward router (NEO-127)** section: operation entry point, reason codes, apply order, idempotency + store interaction, pointer to NEO-128 wiring. +- **`documentation_and_implementation_alignment.md`** — E7.M2 row: router apply landed (NEO-127). +- **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor for **`RewardRouterOperations`**. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs` | Stable router deny reason strings. | +| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs` | Structured outcome + grant snapshots + optional event. | +| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | `TryDeliverQuestCompletion` apply + rollback + store record. | +| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | AAA happy path, deny, rollback, idempotent replay tests. | +| `docs/plans/NEO-127-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/README.md` | Document `RewardRouterOperations`, reason codes, idempotency flow. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — router apply (NEO-127). | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Add NEO-127 implementation anchor after delivery store bullet. | + +## Tests + +| Target | Coverage | +|--------|----------| +| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | Skill-only + item+skill happy paths; `inventory_full` fail-closed; item compensating rollback on skill deny; store idempotent replay; `mission_reward` apply path. | +| **Optional** — extend existing host/DI test | Factory resolves all router dependencies (smoke only). | +| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter RewardRouterOperationsTests`; dev server boot unchanged until NEO-128 wires quest complete. | + +## Open questions / risks + +| Question or risk | Agent recommendation | Status | +|------------------|----------------------|--------| +| Multi-row skill XP partial apply | Prototype bundles have ≤1 skill row; implement sequential apply with item rollback on any skill deny; defer XP compensating rollback unless a future story requires multi-row bundles. | **adopted** | +| `MissionRewardSkillXpGrant` vs `TryApplyGrant` | Use **`TryApplyGrant`** directly for deny envelopes; keep **`MissionRewardSkillXpGrant`** as HTTP/convenience wrapper only. | **adopted** | +| Null bundle on future quests | Treat as empty grants + record delivery for idempotency; NEO-128 may skip router call when bundle absent — both safe. | **adopted** | +| Quest-state rollback on deny | NEO-128 scope per E7M2 fail-closed decision; NEO-127 returns structured deny only. | **deferred** | + +None beyond the above. diff --git a/docs/reviews/2026-06-07-NEO-127.md b/docs/reviews/2026-06-07-NEO-127.md new file mode 100644 index 0000000..56f2460 --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-127.md @@ -0,0 +1,61 @@ +# Code review — NEO-127 (E7M2-04) + +**Date:** 2026-06-07 +**Scope:** Branch `NEO-127-e7m2-reward-router-operations` vs `origin/main` — commits `35b8904` … `65ca111` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** — router apply, compensating rollback, idempotent replay, and store record flow mirror the NEO-105 encounter-completion precedent; plan AC and module contracts are satisfied; risk is low (server-internal only until NEO-128 wiring). + +## Summary + +NEO-127 adds **`RewardRouterOperations.TryDeliverQuestCompletion`** under `Game/Rewards/` to apply quest **`completionRewardBundle`** rows: item grants via **`PlayerInventoryOperations`** (simulate-all pre-flight, sequential apply, compensating rollback) and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind. **`RewardDeliveryResult`** and **`RewardDeliveryReasonCodes`** provide structured outcomes; **`IRewardDeliveryStore`** records **`RewardDeliveryEvent`** on success with idempotent replay via **`TryGet`** before apply and race handling when **`TryRecord`** returns `false`. Ten AAA integration tests cover happy paths, inventory deny, skill deny rollback, store replay, unknown-skill deny, null-bundle empty record, and invalid-id deny. Docs (`NEO-127` plan reconciliation, E7.M2 module anchor, alignment register, `server/README.md`) are updated. No player-visible behavior until NEO-128 wires quest completion. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-127-implementation-plan.md` | **Matches** — kickoff decisions adopted; AC checked; reconciliation accurate. | +| `docs/plans/E7M2-prototype-backlog.md` (E7M2-04) | **Matches** — AC checkboxes complete; in-scope skill XP path aligned with **`TryApplyGrant`**. | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-127 implementation anchor documents router apply path. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-04 router apply; row correctly stays **Planned** until NEO-132 capstone. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required for this slice. | +| `server/README.md` | **Matches** — reward router section documents entry point, reason codes, apply order, idempotency + store race behavior. | +| Full-stack epic decomposition | **N/A** — E7M2-04 is server engine only; no client counterpart required. | + +**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate. + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Empty / null bundle test** — Kickoff decision and plan both require success with empty grant snapshots and **`TryRecord`** for null or both-empty bundles. Code handles this (skips inventory pre-flight, records empty arrays), but no test locks the behavior. A single AAA test with `bundle: null` or `new QuestRewardBundleRow([], [])` would close the gap.~~ **Done.** `TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull`. + +2. ~~**Invalid id deny tests** — `invalid_player_id` / `invalid_quest_id` are implemented and documented in `server/README.md`, but untested. Two small deny-path tests (empty/whitespace ids) would match the fail-closed store precedent from NEO-126.~~ **Done.** `TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty` and `…InvalidQuestId…`. + +3. ~~**Replay XP assertion** — In `TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly`, the second-call assertion reuses `salvageXp` captured before the replay `Deliver` call. Re-fetch **`GetXpTotals`** after the second call to prove no double XP grant on idempotent replay.~~ **Done.** Gather intro test re-fetches XP after replay. + +## Nits + +- Nit: Pre-flight **`TrySimulateAddStack`** failure always denies with **`inventory_full`**, even when the underlying cause would be **`invalid_item`** or **`invalid_quantity`** at apply time. Same as **`EncounterCompletionOperations`** (NEO-105); acceptable for prototype catalog-gated content; apply-phase denies still return correct passthrough codes. + +- Nit: When **`TryRecord`** returns `false` and a subsequent **`TryGet`** also misses, the router still returns success with the locally built **`deliveryEvent`** (line 149 fallback). Extremely unlikely with the in-memory store; mirrors “retain grants, treat as idempotent success” policy from the plan. Consider a debug log if this path ever surfaces in production persistence. + +- Nit: **`CompensatingRemoveItems`** ignores **`TryRemoveStack`** outcomes (same as encounter compensating helper). Acceptable for prototype; multi-item bundles with partial rollback failure would be a future hardening item. + +- Nit: Optional plan test for **`source_kind_not_allowed`** negative path not implemented; catalog startup gates make this low priority. + +## Verification + +```bash +dotnet test server/NeonSprawl.Server.Tests --filter RewardRouterOperationsTests +``` + +All 14 tests pass (verified 2026-06-07; includes concurrent level-up winner perk preservation on **`TryRecord`** race loss). + +Optional before merge: + +- Boot dev server; confirm `/health` 200 — no new DI registration; behavior unchanged until NEO-128. diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs new file mode 100644 index 0000000..3c45a7b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs @@ -0,0 +1,565 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; +using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Rewards; + +public sealed class RewardRouterOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string GatherQuestId = "prototype_quest_gather_intro"; + private const string OperatorChainQuestId = "prototype_quest_operator_chain"; + private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1"; + private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); + var timeProvider = TimeProvider.System; + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + + // Act + var first = Deliver(deps, GatherQuestId, bundle, timeProvider); + var second = Deliver(deps, GatherQuestId, bundle, timeProvider); + + // Assert + Assert.True(first.Success); + Assert.Null(first.ReasonCode); + Assert.Empty(first.GrantedItems); + Assert.Single(first.GrantedSkillXp); + Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId); + Assert.Equal(25, first.GrantedSkillXp[0].Amount); + Assert.NotNull(first.DeliveryEvent); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); + Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); + Assert.Equal(salvageBefore + 25, salvageXp); + Assert.True(second.Success); + Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode); + var xpAfterReplay = deps.SkillProgressionStore.GetXpTotals(PlayerId); + Assert.True(xpAfterReplay.TryGetValue("salvage", out var salvageAfterReplay)); + Assert.Equal(salvageBefore + 25, salvageAfterReplay); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)]); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + + // Act + var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Single(result.GrantedItems); + Assert.Equal("survey_drone_kit", result.GrantedItems[0].ItemId); + Assert.Equal(1, result.GrantedItems[0].Quantity); + Assert.Single(result.GrantedSkillXp); + Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId); + Assert.Equal(50, result.GrantedSkillXp[0].Amount); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit")); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); + Assert.Equal(salvageBefore + 50, salvageXp); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + FillBag(deps); + var bundle = new QuestRewardBundleRow( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)]); + + // Act + var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); + Assert.Empty(result.GrantedItems); + Assert.Empty(result.GrantedSkillXp); + Assert.Null(result.DeliveryEvent); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var bundle = new QuestRewardBundleRow( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 0)]); + + // Act + var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.InvalidAmount, result.ReasonCode); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.Equal( + CountBagItem(beforeInventory!, "survey_drone_kit"), + CountBagItem(afterInventory!, "survey_drone_kit")); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var existingEvent = new RewardDeliveryEvent( + PlayerId, + OperatorChainQuestId, + DeliveredAt, + [new RewardItemGrantApplied("survey_drone_kit", 1)], + [new RewardSkillXpGrantApplied("salvage", 50)], + RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId)); + Assert.True(deps.DeliveryStore.TryRecord(existingEvent)); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var bundle = new QuestRewardBundleRow( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)]); + + // Act + var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); + Assert.Equal(existingEvent.DeliveredAt, result.DeliveredAt); + Assert.Equal(existingEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + Assert.Equal( + CountBagItem(beforeInventory!, "survey_drone_kit"), + CountBagItem(afterInventory!, "survey_drone_kit")); + Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind_WhenCatalogAllowsIt() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("refine", 25)]); + + // Act + var result = Deliver(deps, "prototype_quest_refine_intro", bundle, TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + var totals = deps.SkillProgressionStore.GetXpTotals(PlayerId); + Assert.True(totals.TryGetValue("refine", out var refineXp)); + Assert.Equal(25, refineXp); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldDenyUnknownSkill_WhenSkillIdIsInvalid() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("unknown_skill_id", 25)]); + + // Act + var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.UnknownSkill, result.ReasonCode); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore); + + // Act + var result = Deliver(deps, GatherQuestId, bundle: null, TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Empty(result.GrantedItems); + Assert.Empty(result.GrantedSkillXp); + Assert.NotNull(result.DeliveryEvent); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored)); + Assert.Empty(stored.GrantedItems); + Assert.Empty(stored.GrantedSkillXp); + Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId)); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); + Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); + + // Act + var result = RewardRouterOperations.TryDeliverQuestCompletion( + " ", + GatherQuestId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.InvalidPlayerId, result.ReasonCode); + Assert.False(deps.DeliveryStore.TryGet(" ", GatherQuestId, out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldDenyInvalidQuestId_WhenQuestIdIsEmpty() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); + + // Act + var result = RewardRouterOperations.TryDeliverQuestCompletion( + PlayerId, + " ", + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + TimeProvider.System); + + // Assert + Assert.False(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.InvalidQuestId, result.ReasonCode); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var winnerEvent = new RewardDeliveryEvent( + PlayerId, + GatherQuestId, + DeliveredAt, + [], + [new RewardSkillXpGrantApplied("salvage", 25)], + RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); + var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + + // Act + var result = RewardRouterOperations.TryDeliverQuestCompletion( + PlayerId, + GatherQuestId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + store, + TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); + Assert.Equal(winnerEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0; + Assert.Equal(salvageBefore, salvageAfter); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425)); + var pick = deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency"); + Assert.True(pick.Success); + var perkBefore = deps.PerkStore.GetSnapshot(PlayerId); + Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkBefore.UnlockedPerkIds); + var winnerEvent = new RewardDeliveryEvent( + PlayerId, + GatherQuestId, + DeliveredAt, + [], + [new RewardSkillXpGrantApplied("salvage", 25)], + RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId)); + var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); + + // Act + var result = RewardRouterOperations.TryDeliverQuestCompletion( + PlayerId, + GatherQuestId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + store, + TimeProvider.System); + + // Assert + Assert.True(result.Success); + Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); + Assert.Equal(425, salvageAfter); + var perkAfter = deps.PerkStore.GetSnapshot(PlayerId); + Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds); + Assert.Equal(perkBefore.UnlockedPerkIds, perkAfter.UnlockedPerkIds); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldNotDoubleGrant_WhenConcurrentRecordRaces() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var bundle = new QuestRewardBundleRow( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 25)]); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore); + var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId); + var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0; + var kitBefore = CountBagItem(inventoryBefore!, "survey_drone_kit"); + var barrier = new Barrier(2); + RewardDeliveryResult result1 = default; + RewardDeliveryResult result2 = default; + + // Act + await Task.WhenAll( + Task.Run(() => + { + barrier.SignalAndWait(); + result1 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + }), + Task.Run(() => + { + barrier.SignalAndWait(); + result2 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System); + })); + + // Assert + Assert.True(result1.Success); + Assert.True(result2.Success); + deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); + Assert.Equal(salvageBefore + 25, salvageAfter); + Assert.Equal(kitBefore + 1, CountBagItem(inventoryAfter!, "survey_drone_kit")); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); + } + + [Fact] + public async Task TryDeliverQuestCompletion_ShouldPreserveWinnerPerks_WhenConcurrentRecordRacesAfterLevelUp() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425)); + Assert.True(deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency").Success); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); + var barrier = new Barrier(2); + RewardDeliveryResult result1 = default; + RewardDeliveryResult result2 = default; + + // Act + await Task.WhenAll( + Task.Run(() => + { + barrier.SignalAndWait(); + result1 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); + }), + Task.Run(() => + { + barrier.SignalAndWait(); + result2 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); + })); + + // Assert + Assert.True(result1.Success); + Assert.True(result2.Success); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); + Assert.Equal(450, salvageAfter); + var perkAfter = deps.PerkStore.GetSnapshot(PlayerId); + Assert.Contains(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); + } + + private static RewardDeliveryResult Deliver( + RewardRouterTestDependencies deps, + string questId, + QuestRewardBundleRow? bundle, + TimeProvider timeProvider) => + RewardRouterOperations.TryDeliverQuestCompletion( + PlayerId, + questId, + bundle, + deps.ItemRegistry, + deps.InventoryStore, + deps.SkillRegistry, + deps.SkillProgressionStore, + deps.LevelCurve, + deps.PerkUnlockEngine, + deps.PerkStore, + deps.DeliveryStore, + timeProvider); + + private static void FillBag(RewardRouterTestDependencies 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 RewardRouterTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) + { + var services = factory.Services; + return new RewardRouterTestDependencies( + services.GetRequiredService(), + services.GetRequiredService(), + 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 RewardRouterTestDependencies( + IItemDefinitionRegistry ItemRegistry, + IPlayerInventoryStore InventoryStore, + ISkillDefinitionRegistry SkillRegistry, + IPlayerSkillProgressionStore SkillProgressionStore, + ISkillLevelCurve LevelCurve, + PerkUnlockEngine PerkUnlockEngine, + IPlayerPerkStateStore PerkStore, + IRewardDeliveryStore DeliveryStore); + + /// TryGet misses until after a failed — simulates concurrent race loser. + private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore + { + private bool recordAttempted; + + public bool TryRecord(RewardDeliveryEvent deliveryEvent) + { + recordAttempted = true; + return false; + } + + public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) + { + if (!recordAttempted) + { + deliveryEvent = default; + return false; + } + + deliveryEvent = winnerEvent; + return true; + } + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs index 5ddc70e..131157a 100644 --- a/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs @@ -14,9 +14,15 @@ public interface IPlayerPerkStateStore /// Adds perks not already unlocked; fails when player cannot be written. bool TryAddUnlockedPerks(string playerId, IEnumerable perkIds); + /// Removes unlocked perks present in the store; fails when player cannot be written. + bool TryRemoveUnlockedPerks(string playerId, IEnumerable perkIds); + /// Removes a branch pick (engine rollback when perk unlock fails after pick). bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex); /// Clears all branch picks and unlocked perks for the player (NEO-48 dev fixture API). bool TryResetPerkState(string playerId); + + /// Replaces branch picks and unlocked perks with (grant rollback). + bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot); } diff --git a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs index 8e904fa..5883dea 100644 --- a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs @@ -91,6 +91,32 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o } } + /// + public bool TryRemoveUnlockedPerks(string playerId, IEnumerable perkIds) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return false; + } + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + foreach (var raw in perkIds) + { + var pid = raw.Trim(); + if (pid.Length == 0) + { + continue; + } + + inner.RemoveUnlockedPerk(pid); + } + + return true; + } + } + /// public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex) { @@ -123,6 +149,35 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o } } + /// + public bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return false; + } + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + inner.Reset(); + foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId) + { + foreach (var (tierIndex, branchId) in tiers) + { + inner.SetBranchPick(skillId, tierIndex, branchId); + } + } + + foreach (var perkId in snapshot.UnlockedPerkIds) + { + inner.AddUnlockedPerk(perkId); + } + + return true; + } + } + private static string NormalizePlayerId(string? playerId) { var t = playerId?.Trim(); @@ -156,6 +211,8 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId); + public void RemoveUnlockedPerk(string perkId) => unlockedPerks.Remove(perkId); + public bool RemoveBranchPick(string skillId, int tierIndex) => branchPicks.TryGetValue(skillId, out var tiers) && tiers.Remove(tierIndex); diff --git a/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs index d455cf8..d108260 100644 --- a/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs @@ -193,6 +193,45 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou return true; } + /// + public bool TryRemoveUnlockedPerks(string playerId, IEnumerable perkIds) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + return false; + } + + PostgresPerkStateBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, norm, tx)) + { + tx.Rollback(); + return false; + } + + foreach (var raw in perkIds) + { + var perkId = raw.Trim(); + if (perkId.Length == 0) + { + continue; + } + + using var delete = new Npgsql.NpgsqlCommand( + "DELETE FROM player_unlocked_perk WHERE player_id = @pid AND perk_id = @perk;", + conn, + tx); + delete.Parameters.AddWithValue("pid", norm); + delete.Parameters.AddWithValue("perk", perkId); + delete.ExecuteNonQuery(); + } + + tx.Commit(); + return true; + } + /// public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex) { @@ -257,6 +296,79 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou return true; } + /// + public bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + return false; + } + + PostgresPerkStateBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, norm, tx)) + { + tx.Rollback(); + return false; + } + + using (var picks = new Npgsql.NpgsqlCommand( + "DELETE FROM player_mastery_branch_pick WHERE player_id = @pid;", + conn, + tx)) + { + picks.Parameters.AddWithValue("pid", norm); + picks.ExecuteNonQuery(); + } + + using (var perks = new Npgsql.NpgsqlCommand( + "DELETE FROM player_unlocked_perk WHERE player_id = @pid;", + conn, + tx)) + { + perks.Parameters.AddWithValue("pid", norm); + perks.ExecuteNonQuery(); + } + + foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId) + { + foreach (var (tierIndex, branchId) in tiers) + { + using var insert = new Npgsql.NpgsqlCommand( + """ + INSERT INTO player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id, updated_at) + VALUES (@pid, @sid, @tier, @bid, now()); + """, + conn, + tx); + insert.Parameters.AddWithValue("pid", norm); + insert.Parameters.AddWithValue("sid", skillId); + insert.Parameters.AddWithValue("tier", tierIndex); + insert.Parameters.AddWithValue("bid", branchId); + insert.ExecuteNonQuery(); + } + } + + foreach (var perkId in snapshot.UnlockedPerkIds) + { + using var upsert = new Npgsql.NpgsqlCommand( + """ + INSERT INTO player_unlocked_perk (player_id, perk_id, unlocked_at) + VALUES (@pid, @perk, now()); + """, + conn, + tx); + upsert.Parameters.AddWithValue("pid", norm); + upsert.Parameters.AddWithValue("perk", perkId); + upsert.ExecuteNonQuery(); + } + + tx.Commit(); + return true; + } + private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx) { using var cmd = new Npgsql.NpgsqlCommand( diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs new file mode 100644 index 0000000..21bf15c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs @@ -0,0 +1,37 @@ +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Rewards; + +/// Stable deny reason codes for quest completion reward delivery (NEO-127). +public static class RewardDeliveryReasonCodes +{ + /// Idempotent replay — success path when hits before apply. + public const string AlreadyDelivered = "already_delivered"; + + public const string InvalidPlayerId = "invalid_player_id"; + + public const string InvalidQuestId = "invalid_quest_id"; + + public const string InventoryStoreMissing = "inventory_store_missing"; + + public const string ProgressionStoreMissing = "progression_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; + + /// Passthrough from . + public const string UnknownSkill = SkillProgressionSnapshotApi.ReasonUnknownSkill; + + /// Passthrough from . + public const string InvalidAmount = SkillProgressionSnapshotApi.ReasonInvalidAmount; + + /// Passthrough from . + public const string SourceKindNotAllowed = SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed; +} diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs new file mode 100644 index 0000000..63078ad --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs @@ -0,0 +1,16 @@ +namespace NeonSprawl.Server.Game.Rewards; + +/// +/// Server-internal quest completion reward delivery envelope (NEO-127). +/// Quest-state wiring promotes via NEO-128; HTTP DTOs via NEO-129. +/// +/// Item grants on success; empty when denied. +/// Skill XP grants on success; empty when denied. +/// Delivery payload on success; null when denied. +public readonly record struct RewardDeliveryResult( + bool Success, + string? ReasonCode, + IReadOnlyList GrantedItems, + IReadOnlyList GrantedSkillXp, + RewardDeliveryEvent? DeliveryEvent, + DateTimeOffset? DeliveredAt); diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs new file mode 100644 index 0000000..32cb573 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs @@ -0,0 +1,241 @@ +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Rewards; + +/// +/// Applies quest completion grants idempotently (NEO-127). +/// Quest-state wiring: (NEO-128). +/// +public static class RewardRouterOperations +{ + private static readonly RewardItemGrantApplied[] EmptyItemGrants = []; + private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = []; + + /// + /// Applies item + skill XP rows for a quest completion and records + /// when grants succeed. + /// + public static RewardDeliveryResult TryDeliverQuestCompletion( + string playerId, + string questId, + QuestRewardBundleRow? bundle, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine, + IPlayerPerkStateStore perkStore, + IRewardDeliveryStore deliveryStore, + TimeProvider timeProvider) + { + var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId); + if (normalizedPlayerId.Length == 0) + { + return Deny(RewardDeliveryReasonCodes.InvalidPlayerId); + } + + var normalizedQuestId = RewardDeliveryIds.NormalizeQuestId(questId); + if (normalizedQuestId.Length == 0) + { + return Deny(RewardDeliveryReasonCodes.InvalidQuestId); + } + + if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var existingEvent)) + { + return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered); + } + + var itemGrants = bundle?.ItemGrants ?? Array.Empty(); + var skillGrants = bundle?.SkillXpGrants ?? Array.Empty(); + + if (itemGrants.Count > 0) + { + if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot)) + { + return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing); + } + + var simulated = snapshot.Clone(); + foreach (var grant in itemGrants) + { + if (!PlayerInventoryOperations.TrySimulateAddStack( + ref simulated, + grant.ItemId, + grant.Quantity, + itemRegistry)) + { + return Deny(RewardDeliveryReasonCodes.InventoryFull); + } + } + } + + var appliedItems = new List(itemGrants.Count); + foreach (var grant in itemGrants) + { + var addOutcome = PlayerInventoryOperations.TryAddStack( + playerId, + grant.ItemId, + grant.Quantity, + itemRegistry, + inventoryStore); + + if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing); + } + + if (addOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + return Deny(MapInventoryReason(addOutcome.ReasonCode)); + } + + appliedItems.Add(new RewardItemGrantApplied(grant.ItemId, grant.Quantity)); + } + + var perksUnlockedByThisCall = new HashSet(StringComparer.Ordinal); + var appliedSkillXp = new List(skillGrants.Count); + foreach (var grant in skillGrants) + { + var perkBeforeGrant = perkStore.GetSnapshot(playerId); + var skillOutcome = SkillProgressionGrantOperations.TryApplyGrant( + playerId, + grant.SkillId, + grant.Amount, + MissionRewardSkillXpConstants.MissionRewardSourceKind, + skillRegistry, + skillProgressionStore, + levelCurve, + perkUnlockEngine); + + if (skillOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing) + { + CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing); + } + + if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied) + { + CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + var reasonCode = skillOutcome.Response?.ReasonCode ?? RewardDeliveryReasonCodes.UnknownSkill; + return Deny(reasonCode); + } + + appliedSkillXp.Add(new RewardSkillXpGrantApplied(grant.SkillId, grant.Amount)); + SkillProgressionGrantOperations.CollectPerksUnlockedSince( + perkBeforeGrant, + perkStore.GetSnapshot(playerId), + perksUnlockedByThisCall); + } + + var deliveredAt = timeProvider.GetUtcNow(); + var deliveryEvent = new RewardDeliveryEvent( + normalizedPlayerId, + normalizedQuestId, + deliveredAt, + appliedItems, + appliedSkillXp, + RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId)); + + if (deliveryStore.TryRecord(deliveryEvent)) + { + return SuccessFromEvent(deliveryEvent, reasonCode: null); + } + + // Lost TryRecord race after apply — rollback this call's grants (mirror + // EncounterCompletionOperations when TryMarkCompleted returns false). + CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); + _ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants( + playerId, + appliedSkillXp, + perksUnlockedByThisCall, + skillProgressionStore, + perkStore); + ResyncPathAutoPerksAfterRaceRollback( + playerId, + appliedSkillXp, + skillProgressionStore, + levelCurve, + perkUnlockEngine); + + if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent)) + { + return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered); + } + + return Deny(RewardDeliveryReasonCodes.AlreadyDelivered); + } + + private static void ResyncPathAutoPerksAfterRaceRollback( + string playerId, + IReadOnlyList appliedSkillXp, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine) + { + if (appliedSkillXp.Count == 0) + { + return; + } + + var xpBySkill = skillProgressionStore.GetXpTotals(playerId); + foreach (var grant in appliedSkillXp) + { + xpBySkill.TryGetValue(grant.SkillId, out var totalXp); + var level = levelCurve.LevelFromTotalXp(totalXp); + _ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, grant.SkillId, level); + } + } + + private static string MapInventoryReason(string? reasonCode) => + reasonCode switch + { + PlayerInventoryReasonCodes.InventoryFull => RewardDeliveryReasonCodes.InventoryFull, + PlayerInventoryReasonCodes.InvalidItem => RewardDeliveryReasonCodes.InvalidItem, + PlayerInventoryReasonCodes.InvalidQuantity => RewardDeliveryReasonCodes.InvalidQuantity, + null => RewardDeliveryReasonCodes.InventoryFull, + _ => reasonCode, + }; + + private static void CompensatingRemoveItems( + 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 RewardDeliveryResult Deny(string reasonCode) => + new( + Success: false, + ReasonCode: reasonCode, + GrantedItems: EmptyItemGrants, + GrantedSkillXp: EmptySkillXpGrants, + DeliveryEvent: null, + DeliveredAt: null); + + private static RewardDeliveryResult SuccessFromEvent(RewardDeliveryEvent deliveryEvent, string? reasonCode) => + new( + Success: true, + ReasonCode: reasonCode, + GrantedItems: deliveryEvent.GrantedItems, + GrantedSkillXp: deliveryEvent.GrantedSkillXp, + DeliveryEvent: deliveryEvent, + DeliveredAt: deliveryEvent.DeliveredAt); +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs index 2f7651c..0d27599 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Skills; @@ -101,4 +102,40 @@ public static class SkillProgressionGrantOperations LevelUps = levelUps, }); } + + /// Collects perk ids unlocked between two snapshots (this batch only). + public static void CollectPerksUnlockedSince( + PerkStateSnapshot before, + PerkStateSnapshot after, + ISet sink) + { + foreach (var perkId in after.UnlockedPerkIds) + { + if (!before.UnlockedPerkIds.Contains(perkId)) + { + sink.Add(perkId); + } + } + } + + /// Reverts skill XP grants and perks unlocked during this failed batch commit. + public static bool TryRevertAppliedSkillGrants( + string playerId, + IReadOnlyList appliedGrants, + IReadOnlyCollection perksUnlockedByBatch, + IPlayerSkillProgressionStore xpStore, + IPlayerPerkStateStore perkStore) + { + for (var i = appliedGrants.Count - 1; i >= 0; i--) + { + var grant = appliedGrants[i]; + if (!xpStore.TryApplyXpDelta(playerId, grant.SkillId, -grant.Amount, out _, out _)) + { + return false; + } + } + + return perksUnlockedByBatch.Count == 0 + || perkStore.TryRemoveUnlockedPerks(playerId, perksUnlockedByBatch); + } } diff --git a/server/README.md b/server/README.md index 5439d16..8b46293 100644 --- a/server/README.md +++ b/server/README.md @@ -267,7 +267,7 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: ## Reward delivery store (NEO-126) -**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully (router apply lands in NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**). +**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**). | Field | Role | |-------|------| @@ -284,6 +284,21 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest completion rewards use this store only. Postgres persistence deferred. Plan: [NEO-126 implementation plan](../../docs/plans/NEO-126-implementation-plan.md). +## Reward router (NEO-127) + +**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows) and **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**. + +| Reason code | When | +|-------------|------| +| **`already_delivered`** | Success replay — store already has a delivery for this player+quest (no re-grant). | +| **`invalid_player_id`**, **`invalid_quest_id`** | Normalized id empty after trim. | +| **`inventory_store_missing`** | Player inventory store could not read/write. | +| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. | +| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. | +| **`progression_store_missing`** | Skill progression store could not apply XP delta. | + +Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md). + ## Encounter definitions (NEO-103) **`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.