diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index 08e6e0d..e16f9b3 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -59,6 +59,10 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. **Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147). +**Economy validation at issue (NEO-150):** **`ContractEconomyValidation.TryValidateTemplate`** — band caps + reward-bundle cross-refs before persist; deny **`economy_cap_exceeded`** / **`invalid_reward_bundle`**; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147). + +**Reward parity stub:** prototype band caps enforce Slice 4 policy only. Full [E6.M4](E6_M4_RewardParityEnforcer.md) **`RewardParityMap`** and [E3.M5](E3_M5_EconomyBalancePolicy.md) balance dashboards are deferred. + **Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127). **Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index fe72db9..c292b62 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -62,7 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E7.M2 | Ready | **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). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [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-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-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 | | E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`NEO-143` manual QA](../../manual-qa/NEO-143.md); [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143); plan [NEO-143](../../plans/NEO-143-implementation-plan.md). **Epic 7 Slice 3 client capstone complete.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | -| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | +| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **E7M4-03 stores landed ([NEO-146](https://linear.app/neon-sprawl/issue/NEO-146)):** `IContractInstanceStore`, `IContractOutcomeStore`, in-memory + Postgres `V011`/`V012` ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); [server README — Contract instance store (NEO-146)](../../../server/README.md#contract-instance-store-neo-146), [Contract outcome store (NEO-146)](../../../server/README.md#contract-outcome-store-neo-146); Bruno `bruno/neon-sprawl-server/contract-stores/`. **E7M4-04 generator landed ([NEO-147](https://linear.app/neon-sprawl/issue/NEO-147)):** `ContractGeneratorOperations.TryIssue`, `ContractGeneratorReasonCodes`, deterministic `ci_{hex}` instance ids ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **E7M4-05 reward router landed ([NEO-148](https://linear.app/neon-sprawl/issue/NEO-148)):** `RewardRouterOperations.TryDeliverContractCompletion`, source-kind-aware `IRewardDeliveryStore` / `RewardDeliveryEvent` extension ([NEO-148 plan](../../plans/NEO-148-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127), [Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M4-06 completion wiring landed ([NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)):** `ContractCompletionOperations.TryCompleteOnEncounterClear`, encounter hook after E5.M3 loot + quest wiring, `QuestFixtureRequest.resetContractInstanceIds` ([NEO-149 plan](../../plans/NEO-149-implementation-plan.md)); [server README — Contract completion operations (NEO-149)](../../../server/README.md#contract-completion-operations-neo-149); Bruno `bruno/neon-sprawl-server/contract-stores/Reset contract instance via quest fixture.bru`. **E7M4-07 economy validation landed ([NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)):** `ContractEconomyValidation.TryValidateTemplate` at issue — band caps + cross-ref deny codes ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)); [server README — Contract generator operations (NEO-147)](../../../server/README.md#contract-generator-operations-neo-147). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), [NEO-146](../../plans/NEO-146-implementation-plan.md), [NEO-147](../../plans/NEO-147-implementation-plan.md), [NEO-148](../../plans/NEO-148-implementation-plan.md), [NEO-149](../../plans/NEO-149-implementation-plan.md), [NEO-150](../../plans/NEO-150-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 8f9441e..6a4911d 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -118,7 +118,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl **E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete. | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | In Progress | -**E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); **E7M4-03 / NEO-146** contract instance + outcome stores landed ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); **E7M4-04 / NEO-147** contract generator **`TryIssue`** landed ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). +**E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); **E7M4-03 / NEO-146** contract instance + outcome stores landed ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); **E7M4-04 / NEO-147** contract generator **`TryIssue`** landed ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)); **E7M4-07 / NEO-150** issue-time economy validation landed ([NEO-150 plan](../../plans/NEO-150-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). ### Epic 8 — Social / Guild diff --git a/docs/plans/NEO-150-implementation-plan.md b/docs/plans/NEO-150-implementation-plan.md new file mode 100644 index 0000000..4569d04 --- /dev/null +++ b/docs/plans/NEO-150-implementation-plan.md @@ -0,0 +1,172 @@ +# NEO-150 — E7M4-07: Economy validation lint at issue time + +**Linear:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) +**Branch:** `NEO-150-e7m4-07-economy-validation-lint-at-issue-time` +**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-07** +**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) +**Pattern:** [NEO-145-implementation-plan.md](NEO-145-implementation-plan.md) (CI-parity gates + `PrototypeE7M4ContractCatalogRules`); [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) (`ContractGeneratorOperations.TryIssue` orchestrator) +**Precursor:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) **`Done`** — `ContractGeneratorOperations.TryIssue` (**on `main`**) +**Blocks:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) (E7M4-08 contract issue HTTP) +**Client counterpart:** none (server policy); readable deny copy lands in **NEO-153** (E7M4-10) + +## Goal + +Slice 4 AC — issued contracts validate against **prototype economy caps** and **reward bundle cross-refs** at issue time (defense in depth after CI + startup catalog load). + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|-------|----------|---------------------|--------| +| Deny reason codes | Single vs split codes for cap vs cross-ref failures? | **Split:** `economy_cap_exceeded` for band-cap violations; `invalid_reward_bundle` for unknown item/skill/faction refs or disallowed skill XP source — keeps E7M4-10 HUD copy precise and matches backlog unit-test split (over-cap vs unknown id). | **Adopted** — user chose separate codes | +| Validation reuse | New logic vs delegate to `PrototypeE7M4ContractCatalogRules`? | **Delegate** — `ContractEconomyValidation.TryValidateTemplate` calls existing `TryGetBandCapGateError` + `TryGetCrossRefError` on a single-row map; same constants as CI/loader (`BandCapsByDifficultyBand`). | **Adopted** — repo precedent (NEO-145) | +| Cross-ref scope at issue | Bundle-only vs full loader parity? | **Full loader parity** for the selected template (encounter, faction gate, bundle item/skill/faction refs, `mission_reward` allowlist) via live registries — matches backlog “registry lookups” and “slipped through” AC. | **Adopted** — backlog in-scope list | +| TryIssue signature | Add registry parameters vs hide inside validation service? | **Add registry parameters** to `TryIssue` (encounter, item, skill, faction registries + skill defs for allowlist) — mirror `ContractCompletionOperations` / loader cross-ref deps; static orchestrator pattern unchanged. | **Adopted** — NEO-147 orchestrator precedent | +| Startup vs issue-time | Re-validate when catalog already passed load? | **Yes** — issue-time lint is fail-closed defense; production path always passes; unit tests inject tampered in-memory templates to prove deny. | **Adopted** — Linear AC “cannot issue if slipped through” | +| HTTP / Godot / telemetry | In scope? | **Out of scope** — NEO-151 HTTP, NEO-153 client HUD, NEO-152 comment hooks. | **Adopted** — backlog | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- **`ContractEconomyValidation.TryValidateTemplate`** — band caps + cross-ref for one `ContractTemplateRow` using `PrototypeE7M4ContractCatalogRules` helpers and injected registries. +- Call from **`ContractGeneratorOperations.TryIssue`** after template selection, **before** `TryCreateActive`; fail-closed deny. +- **`ContractGeneratorReasonCodes`:** `economy_cap_exceeded`, `invalid_reward_bundle`. +- Unit tests: at-cap pass (prototype freeze), over-cap deny, unknown bundle id deny. +- Confirm startup/CI path unchanged: tampered catalog still fails `ContractTemplateCatalogLoader` / `validate_content.py` (existing NEO-144/NEO-145 tests). +- `server/README.md` contract generator section — economy validation reason codes + issue-time gate. +- **E7_M4** module doc — economy validation note + parity stub toward E6.M4 / E3.M5 (full map deferred). + +**Out of scope (from Linear + backlog):** + +- Full **`RewardParityMap`** (E6.M4); live ops dashboards (E9.M2 / E3.M5). +- **`POST …/contracts/issue`** HTTP (NEO-151). +- Godot client deny copy (NEO-153). +- Telemetry **`reward_anomaly`** comment hooks (NEO-152). + +## Acceptance criteria checklist + +- [x] Template at freeze caps issues successfully. +- [x] Tampered catalog row over caps fails at startup (CI) and cannot issue if slipped through. +- [x] `dotnet test` covers economy validation gates. + +## Implementation reconciliation (shipped) + +- **`ContractEconomyValidation.TryValidateTemplate`** — delegates to `PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError` + `TryGetCrossRefError` for one template row against live registries. +- **`ContractGeneratorOperations.TryIssue`** — extended signature (encounter/item/skill/faction registries); economy validation after template selection, before persist. +- **`ContractGeneratorReasonCodes`:** `economy_cap_exceeded`, `invalid_reward_bundle`. +- **Tests:** `ContractEconomyValidationTests` (4 unit), `ContractGeneratorOperationsTests` (+2 deny paths), shared `ContractPrototypeTestFixtures`; all `TryIssue` call sites updated; **912** tests green. +- **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register, module dependency register. + +## Technical approach + +### 1. `ContractEconomyValidation` (`Game/Contracts/`) + +Static helper (mirror `MoveCommandValidation` shape): + +```csharp +public static bool TryValidateTemplate( + ContractTemplateRow template, + IEncounterDefinitionRegistry encounterRegistry, + IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IFactionDefinitionRegistry factionRegistry, + out string? reasonCode) +``` + +**Flow:** + +1. Build single-row `Dictionary` for the selected template id. +2. **`TryGetBandCapGateError`** — if non-null → `reasonCode = economy_cap_exceeded`, return false. +3. **`TryGetCrossRefError`** — pass `knownEncounterIds`, `knownFactionIds`, `skillDefsById` from registries (same inputs as `ContractTemplateCatalogLoader`). +4. If cross-ref error → `reasonCode = invalid_reward_bundle`, return false. +5. Otherwise return true. + +Internal detail strings from gate helpers stay server-side; orchestrator exposes only stable reason codes. + +### 2. `ContractGeneratorOperations.TryIssue` + +After successful `TrySelectTemplate`, before `MakeDeterministicInstanceId` / `TryCreateActive`: + +```csharp +if (!ContractEconomyValidation.TryValidateTemplate( + template!, encounterRegistry, itemRegistry, skillRegistry, factionRegistry, out var economyReason)) +{ + return Deny(economyReason!); +} +``` + +Extend signature with: + +- `IEncounterDefinitionRegistry encounterRegistry` +- `IItemDefinitionRegistry itemRegistry` +- `ISkillDefinitionRegistry skillRegistry` +- `IFactionDefinitionRegistry factionRegistry` + +Update all call sites (generator tests, completion tests, encounter tests, quest fixture test, integration tests). + +### 3. Reason codes + +| Code | When | +|------|------| +| **`economy_cap_exceeded`** | Item qty, skill XP, or rep grant exceeds band policy | +| **`invalid_reward_bundle`** | Unknown encounter/item/skill/faction ref or skill missing `mission_reward` allowlist | + +### 4. Docs + +- **`server/README.md`** — extend contract generator reason-code table; note issue-time economy lint (NEO-150). +- **`E7_M4_ContractMissionGenerator.md`** — economy validation at issue + stub: “Prototype band caps enforce Slice 4 policy; full E6.M4 `RewardParityMap` / E3.M5 balance dashboards deferred.” +- **`documentation_and_implementation_alignment.md`** — E7.M4 row note after implementation. + +### 5. Parity stub (E6.M4 / E3.M5) + +One short subsection under E7.M4 module doc — no new E6.M4 implementation; documents that issue-time lint is **prototype band caps only**, not equivalent-power parity. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-150-implementation-plan.md` | This plan | +| `server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs` | `TryValidateTemplate` — band caps + cross-ref for one template | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs` | AAA unit tests for validation helper (at-cap, over-cap, unknown ref) | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs` | Add `EconomyCapExceeded`, `InvalidRewardBundle` constants | +| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs` | Invoke `TryValidateTemplate` before persist; extend `TryIssue` signature | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs` | Wire registries into helper; add over-cap + unknown-ref deny tests; update `TryIssue` calls | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs` | Pass registries from DI | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs` | Pass registries from DI | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs` | Update `TryIssue` call sites | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs` | Update `TryIssue` call sites | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Update `TryIssue` call sites | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs` | Update `TryIssue` call sites | +| `server/README.md` | Document economy validation at issue + reason codes | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-150 economy validation + E6.M4/E3.M5 parity stub | + +## Tests + +| File | What it covers | +|------|----------------| +| `ContractEconomyValidationTests` | At-cap pass (prototype freeze row); over-cap deny → `economy_cap_exceeded`; unknown item id → `invalid_reward_bundle` | +| `ContractGeneratorOperationsTests` | End-to-end: tampered template in in-memory registry denies issue with correct reason codes; prototype happy path still passes | +| `ContractGeneratorOperationsIntegrationTests` | DI path with real catalog still issues successfully | +| `ContractTemplateCatalogLoaderTests` (existing) | Startup fail on over-cap catalog — AC “fails at startup (CI)”; no change expected | +| CI | `python3 scripts/validate_content.py` unchanged; `dotnet test` green | + +Manual Godot QA: **none** (server policy; capstone NEO-154). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Duplicate validation vs startup load | Accept small cost — issue-time lint is explicit Slice 4 AC defense-in-depth | `adopted` | +| `TryIssue` signature churn | Update all call sites in this story; NEO-151 HTTP wrapper inherits extended signature | `adopted` | +| Client deny copy for `invalid_reward_bundle` | Defer readable copy to NEO-153; server ships stable code now | `deferred` | +| Telemetry `reward_anomaly` at economy deny | Defer comment-only hooks to NEO-152 | `deferred` | +| **NEO-151 HTTP `TryIssue` wiring** | Production DI path must pass the same four registries already used in `ContractGeneratorOperationsIntegrationTests` (`IEncounterDefinitionRegistry`, `IItemDefinitionRegistry`, `ISkillDefinitionRegistry`, `IFactionDefinitionRegistry`) — verify at NEO-151 kickoff | `pending` | + +## Client counterpart + +None (server policy). **NEO-153** will surface `economy_cap_exceeded` and `invalid_reward_bundle` after **NEO-151** HTTP lands. diff --git a/docs/reviews/2026-06-28-NEO-150.md b/docs/reviews/2026-06-28-NEO-150.md new file mode 100644 index 0000000..40d26b0 --- /dev/null +++ b/docs/reviews/2026-06-28-NEO-150.md @@ -0,0 +1,64 @@ +# Code review — NEO-150 economy validation at issue time + +**Date:** 2026-06-28 +**Scope:** Branch `NEO-150-e7m4-07-economy-validation-lint-at-issue-time` — commits `039542a`..`d5de4e8` (3 commits) +**Base:** `origin/main` +**Issue:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) — E7M4-07 economy validation lint at issue time + +## Verdict + +**Approve with nits** + +## Summary + +This branch adds defense-in-depth economy validation at contract issue time: `ContractEconomyValidation.TryValidateTemplate` delegates to existing `PrototypeE7M4ContractCatalogRules` band-cap and cross-ref helpers, wired into `ContractGeneratorOperations.TryIssue` after template selection and before persist. Two new reason codes (`economy_cap_exceeded`, `invalid_reward_bundle`) surface stable deny semantics for downstream HTTP (NEO-151) and client copy (NEO-153). + +The implementation matches the adopted kickoff plan: registry parameters on `TryIssue`, split reason codes, full loader-parity cross-ref scope, and no HTTP/client scope creep. All `TryIssue` call sites are updated; unit and integration tests cover happy path, over-cap deny, and unknown bundle ref deny. Full suite passes (911 tests). Risk is low — logic reuses proven CI/startup gates; main residual risk is per-issue registry snapshot allocation cost, acceptable at prototype scale. + +## Documentation checked + +| Document | Result | +|----------|--------| +| `docs/plans/NEO-150-implementation-plan.md` | **Matches** — AC checklist marked complete; shipped reconciliation aligns with diff | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-150 economy validation note + E6.M4/E3.M5 parity stub added | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row updated with E7M4-07 / NEO-150 | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M4 note references NEO-150 | +| `server/README.md` | **Matches** — issue-time economy validation paragraph + reason-code table extended | +| `docs/plans/E7M4-pre-production-backlog.md` | **Not re-read in diff** — plan cites it; backlog intent satisfied by slice scope | + +Register/tracking table updates are already included in this branch. + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Deduplicate prototype registry test fixtures** — `ContractEconomyValidationTests.CreatePrototypeRegistries()` and `ContractGeneratorOperationsTests.CreatePrototypeCatalogRegistries()` are nearly identical (~50 lines). Consider a shared internal test helper (e.g. `ContractPrototypeTestFixtures`) to avoid drift when prototype catalog ids change.~~ **Done.** — `ContractPrototypeTestFixtures.CreateCatalogRegistries()`. + +2. ~~**Broaden deny-path test matrix (optional)** — Current tests exercise item qty over cap and unknown item id only. Band-cap and cross-ref helpers also cover skill XP, reputation grants, unknown encounter, and disallowed `mission_reward` skill sources (covered at loader level in `ContractTemplateCatalogLoaderTests`). One additional `[Theory]` or a skill-Xp-over-cap case in `ContractEconomyValidationTests` would document parity without much cost.~~ **Done.** — `TryValidateTemplate_ShouldDenyEconomyCapExceeded_WhenSkillXpOverBandCap`. + +3. ~~**NEO-151 call-site checklist** — When HTTP lands, confirm the production DI path passes the same four registries already wired in integration tests. No code change needed here; worth a one-line note in NEO-151 plan.~~ **Done.** — handoff row in [NEO-150 plan](../plans/NEO-150-implementation-plan.md) open questions (`pending` for NEO-151 kickoff). + +## Nits + +- ~~Nit: `ContractEconomyValidation.TryValidateTemplate` does not null-check `template`; safe today because `TrySelectTemplate` only returns success with a non-null template, but `ArgumentNullException.ThrowIfNull(template)` would mirror the registry guards.~~ **Done.** +- ~~Nit: Issue-time validation order is band-cap → cross-ref; startup loader runs cross-ref → band-cap. Intentional per plan (cap failures get `economy_cap_exceeded`), but worth keeping in mind if both gates ever fail on the same row during debugging.~~ **Done.** — inline comment in `ContractEconomyValidation`. +- Nit: Per-issue allocation of encounter/faction hash sets and skill dictionary is fine for prototype; revisit only if issue volume becomes hot-path. + +## Verification + +Commands run during review: + +```bash +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractEconomyValidation|FullyQualifiedName~ContractGeneratorOperations" +dotnet test NeonSprawl.sln +``` + +Results: 20 filtered tests passed; **911/911** full suite passed. + +Author pre-merge checklist: + +- [x] `dotnet test NeonSprawl.sln` green +- [ ] Merge to `main` and proceed to NEO-151 (HTTP) with extended `TryIssue` signature +- [ ] No Godot manual QA required (server policy per plan) diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs index 77bc2ba..96b7364 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs @@ -32,6 +32,10 @@ public sealed class ContractCompletionOperationsIntegrationTests SeedBucket, zoneDifficultyBand: null, deps.TemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); @@ -123,6 +127,7 @@ public sealed class ContractCompletionOperationsIntegrationTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), @@ -147,6 +152,7 @@ public sealed class ContractCompletionOperationsIntegrationTests IContractInstanceStore InstanceStore, IContractOutcomeStore OutcomeStore, IEncounterDefinitionRegistry EncounterRegistry, + IFactionDefinitionRegistry FactionRegistry, IRewardTableDefinitionRegistry RewardTableRegistry, IEncounterProgressStore ProgressStore, IEncounterCompletionStore CompletionStore, diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs index 5f77030..9a57dc4 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -165,6 +166,10 @@ public sealed class ContractCompletionOperationsTests SeedBucket, zoneDifficultyBand: null, deps.TemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); @@ -226,6 +231,8 @@ public sealed class ContractCompletionOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), @@ -248,6 +255,8 @@ public sealed class ContractCompletionOperationsTests IContractTemplateRegistry TemplateRegistry, IContractInstanceStore InstanceStore, IContractOutcomeStore OutcomeStore, + IEncounterDefinitionRegistry EncounterRegistry, + IFactionDefinitionRegistry FactionRegistry, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, ISkillDefinitionRegistry SkillRegistry, diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs new file mode 100644 index 0000000..7da5044 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs @@ -0,0 +1,105 @@ +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class ContractEconomyValidationTests +{ + [Fact] + public void TryValidateTemplate_ShouldReturnTrue_WhenPrototypeFreezeRowAtCaps() + { + // Arrange + var template = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId]; + var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries(); + // Act + var ok = ContractEconomyValidation.TryValidateTemplate( + template, + registries.EncounterRegistry, + registries.ItemRegistry, + registries.SkillRegistry, + registries.FactionRegistry, + out var reasonCode); + // Assert + Assert.True(ok); + Assert.Null(reasonCode); + } + + [Fact] + public void TryValidateTemplate_ShouldDenyEconomyCapExceeded_WhenItemQuantityOverBandCap() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId]; + var overCapTemplate = prototype with + { + CompletionRewardBundle = new QuestRewardBundleRow( + [new RewardGrantRow("scrap_metal_bulk", 11)], + prototype.CompletionRewardBundle.SkillXpGrants, + prototype.CompletionRewardBundle.ReputationGrants), + }; + var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries(); + // Act + var ok = ContractEconomyValidation.TryValidateTemplate( + overCapTemplate, + registries.EncounterRegistry, + registries.ItemRegistry, + registries.SkillRegistry, + registries.FactionRegistry, + out var reasonCode); + // Assert + Assert.False(ok); + Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, reasonCode); + } + + [Fact] + public void TryValidateTemplate_ShouldDenyEconomyCapExceeded_WhenSkillXpOverBandCap() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId]; + var overCapTemplate = prototype with + { + CompletionRewardBundle = new QuestRewardBundleRow( + prototype.CompletionRewardBundle.ItemGrants, + [new QuestSkillXpGrantRow("salvage", 26)], + prototype.CompletionRewardBundle.ReputationGrants), + }; + var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries(); + // Act + var ok = ContractEconomyValidation.TryValidateTemplate( + overCapTemplate, + registries.EncounterRegistry, + registries.ItemRegistry, + registries.SkillRegistry, + registries.FactionRegistry, + out var reasonCode); + // Assert + Assert.False(ok); + Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, reasonCode); + } + + [Fact] + public void TryValidateTemplate_ShouldDenyInvalidRewardBundle_WhenItemIdUnknown() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId]; + var unknownItemTemplate = prototype with + { + CompletionRewardBundle = new QuestRewardBundleRow( + [new RewardGrantRow("unknown_item_xyz", 1)], + prototype.CompletionRewardBundle.SkillXpGrants, + prototype.CompletionRewardBundle.ReputationGrants), + }; + var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries(); + // Act + var ok = ContractEconomyValidation.TryValidateTemplate( + unknownItemTemplate, + registries.EncounterRegistry, + registries.ItemRegistry, + registries.SkillRegistry, + registries.FactionRegistry, + out var reasonCode); + // Assert + Assert.False(ok); + Assert.Equal(ContractGeneratorReasonCodes.InvalidRewardBundle, reasonCode); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs index 6e9580c..9de5f42 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; @@ -27,12 +30,20 @@ public sealed class ContractGeneratorOperationsIntegrationTests TemplateId, SeedBucket); // Act + var encounterRegistry = scope.ServiceProvider.GetRequiredService(); + var itemRegistry = scope.ServiceProvider.GetRequiredService(); + var skillRegistry = scope.ServiceProvider.GetRequiredService(); + var factionRegistry = scope.ServiceProvider.GetRequiredService(); var result = ContractGeneratorOperations.TryIssue( PlayerId, TemplateId, SeedBucket, zoneDifficultyBand: null, templateRegistry, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, instanceStore, standingStore, timeProvider); diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs index f10f791..fca68a5 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs @@ -1,7 +1,10 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.Contracts; @@ -206,6 +209,10 @@ public sealed class ContractGeneratorOperationsTests SeedBucket, zoneDifficultyBand: null, templateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, store, standingStore, deps.TimeProvider); @@ -252,6 +259,50 @@ public sealed class ContractGeneratorOperationsTests Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode); } + [Fact] + public void TryIssue_ShouldDenyEconomyCapExceeded_WhenItemQuantityOverBandCap() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId]; + var overCapTemplate = prototype with + { + CompletionRewardBundle = new QuestRewardBundleRow( + [new RewardGrantRow("scrap_metal_bulk", 11)], + prototype.CompletionRewardBundle.SkillXpGrants, + prototype.CompletionRewardBundle.ReputationGrants), + }; + var deps = CreateDependencies(overCapTemplate); + // Act + var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, result.ReasonCode); + Assert.Null(result.Snapshot); + Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); + } + + [Fact] + public void TryIssue_ShouldDenyInvalidRewardBundle_WhenItemIdUnknown() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId]; + var unknownItemTemplate = prototype with + { + CompletionRewardBundle = new QuestRewardBundleRow( + [new RewardGrantRow("unknown_item_xyz", 1)], + prototype.CompletionRewardBundle.SkillXpGrants, + prototype.CompletionRewardBundle.ReputationGrants), + }; + var deps = CreateDependencies(unknownItemTemplate); + // Act + var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.InvalidRewardBundle, result.ReasonCode); + Assert.Null(result.Snapshot); + Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); + } + private static ContractIssueOperationResult TryIssue( GeneratorTestDependencies deps, string? playerId = PlayerId, @@ -264,6 +315,10 @@ public sealed class ContractGeneratorOperationsTests seedBucket, zoneDifficultyBand, deps.TemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); @@ -281,8 +336,13 @@ public sealed class ContractGeneratorOperationsTests var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); var instanceStore = new InMemoryContractInstanceStore(positionOptions); var standingStore = CreateStandingStore(); + var catalogRegistries = ContractPrototypeTestFixtures.CreateCatalogRegistries(); return new GeneratorTestDependencies( templateRegistry, + catalogRegistries.EncounterRegistry, + catalogRegistries.ItemRegistry, + catalogRegistries.SkillRegistry, + catalogRegistries.FactionRegistry, instanceStore, standingStore, new FakeTimeProvider(IssuedAt)); @@ -307,6 +367,10 @@ public sealed class ContractGeneratorOperationsTests private readonly record struct GeneratorTestDependencies( IContractTemplateRegistry TemplateRegistry, + IEncounterDefinitionRegistry EncounterRegistry, + IItemDefinitionRegistry ItemRegistry, + ISkillDefinitionRegistry SkillRegistry, + IFactionDefinitionRegistry FactionRegistry, IContractInstanceStore InstanceStore, IFactionStandingStore StandingStore, TimeProvider TimeProvider); diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs index 5278cc6..2721d2c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs @@ -1,5 +1,8 @@ using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState; @@ -36,6 +39,10 @@ public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegra SeedBucket, zoneDifficultyBand: null, services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractPrototypeTestFixtures.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractPrototypeTestFixtures.cs new file mode 100644 index 0000000..9842dd9 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractPrototypeTestFixtures.cs @@ -0,0 +1,75 @@ +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +/// +/// Shared in-memory prototype definition registries for contract generator and economy tests (NEO-150). +/// Keep ids aligned with freeze table. +/// +internal static class ContractPrototypeTestFixtures +{ + public const string TemplateId = "prototype_contract_clear_combat_pocket"; + public const string EncounterId = "prototype_combat_pocket"; + public const string GridOperatorsFactionId = "prototype_faction_grid_operators"; + + public static ContractPrototypeCatalogRegistries CreateCatalogRegistries() + { + var encounterRows = new Dictionary(StringComparer.Ordinal) + { + [EncounterId] = new( + EncounterId, + "Prototype Combat Pocket", + "defeat_all_targets", + ["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"], + "prototype_combat_pocket_clear"), + }; + var encounterRegistry = new EncounterDefinitionRegistry( + new EncounterDefinitionCatalog("/tmp/encounters", encounterRows, catalogJsonFileCount: 1)); + + var itemRows = new Dictionary(StringComparer.Ordinal) + { + ["scrap_metal_bulk"] = new( + "scrap_metal_bulk", + "Scrap Metal (Bulk)", + "material", + StackMax: 999, + InventorySlotKind: "bag", + Rarity: null, + BindPolicy: null, + DurabilityMax: null), + }; + var itemRegistry = new ItemDefinitionRegistry( + new ItemDefinitionCatalog("/tmp/items", itemRows, catalogJsonFileCount: 1)); + + var skillRows = new Dictionary(StringComparer.Ordinal) + { + ["salvage"] = new("salvage", "gather", "Salvage", ["activity", "mission_reward"]), + ["refine"] = new("refine", "process", "Refine", ["activity", "mission_reward", "trainer"]), + ["intrusion"] = new("intrusion", "tech", "Intrusion", ["activity", "mission_reward", "book_or_item"]), + }; + var skillRegistry = new SkillDefinitionRegistry( + new SkillDefinitionCatalog("/tmp/skills", skillRows, catalogJsonFileCount: 1)); + + var factionRows = new Dictionary(StringComparer.Ordinal) + { + [GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId], + }; + var factionRegistry = new FactionDefinitionRegistry( + new FactionDefinitionCatalog("/tmp/factions", factionRows, catalogJsonFileCount: 1)); + + return new ContractPrototypeCatalogRegistries( + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry); + } +} + +internal sealed record ContractPrototypeCatalogRegistries( + IEncounterDefinitionRegistry EncounterRegistry, + IItemDefinitionRegistry ItemRegistry, + ISkillDefinitionRegistry SkillRegistry, + IFactionDefinitionRegistry FactionRegistry); diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 4a585ec..dc3516a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -28,6 +28,10 @@ public sealed class EncounterCompletionOperationsTests "2026-06-22", zoneDifficultyBand: null, deps.ContractTemplateRegistry, + deps.EncounterRegistry, + deps.ItemRegistry, + deps.SkillRegistry, + deps.FactionRegistry, deps.ContractInstanceStore, deps.StandingStore, TimeProvider.System); @@ -513,7 +517,8 @@ public sealed class EncounterCompletionOperationsTests services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService()); } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) @@ -564,7 +569,8 @@ public sealed class EncounterCompletionOperationsTests IReputationDeltaStore AuditStore, IContractInstanceStore ContractInstanceStore, IContractOutcomeStore ContractOutcomeStore, - IContractTemplateRegistry ContractTemplateRegistry); + IContractTemplateRegistry ContractTemplateRegistry, + IFactionDefinitionRegistry FactionRegistry); private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore { diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs index 979ca77..0369c39 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Hosting; using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -291,12 +292,18 @@ public sealed class QuestFixtureApiTests var timeProvider = factory.Services.GetRequiredService(); const string templateId = "prototype_contract_clear_combat_pocket"; const string encounterId = "prototype_combat_pocket"; + var encounterRegistry = factory.Services.GetRequiredService(); + var factionRegistry = factory.Services.GetRequiredService(); var issued = ContractGeneratorOperations.TryIssue( DevPlayer, templateId, "2026-06-22", zoneDifficultyBand: null, templateRegistry, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, instanceStore, standingStore, timeProvider); diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs b/server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs new file mode 100644 index 0000000..621a1af --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs @@ -0,0 +1,65 @@ +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Issue-time economy and reward-bundle validation for contract templates (NEO-150). +/// Mirrors startup catalog gates for defense in depth at . +/// +public static class ContractEconomyValidation +{ + /// + /// Validates band caps and cross-refs for one template against live registries. + /// + public static bool TryValidateTemplate( + ContractTemplateRow template, + IEncounterDefinitionRegistry encounterRegistry, + IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IFactionDefinitionRegistry factionRegistry, + out string? reasonCode) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(encounterRegistry); + ArgumentNullException.ThrowIfNull(itemRegistry); + ArgumentNullException.ThrowIfNull(skillRegistry); + ArgumentNullException.ThrowIfNull(factionRegistry); + + reasonCode = null; + var rowsById = new Dictionary(StringComparer.Ordinal) + { + [template.Id] = template, + }; + + // Band-cap before cross-ref so deny maps to economy_cap_exceeded (startup loader runs cross-ref first). + if (PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rowsById) is not null) + { + reasonCode = ContractGeneratorReasonCodes.EconomyCapExceeded; + return false; + } + + var knownEncounterIds = encounterRegistry.GetDefinitionsInIdOrder() + .Select(static d => d.Id) + .ToHashSet(StringComparer.Ordinal); + var knownFactionIds = factionRegistry.GetDefinitionsInIdOrder() + .Select(static d => d.Id) + .ToHashSet(StringComparer.Ordinal); + var skillDefsById = skillRegistry.GetDefinitionsInIdOrder() + .ToDictionary(static d => d.Id, StringComparer.Ordinal); + + if (PrototypeE7M4ContractCatalogRules.TryGetCrossRefError( + rowsById, + knownEncounterIds, + knownFactionIds, + skillDefsById) is not null) + { + reasonCode = ContractGeneratorReasonCodes.InvalidRewardBundle; + return false; + } + + return true; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs index ba158b9..8919803 100644 --- a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs @@ -1,10 +1,13 @@ +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Contracts; /// /// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147). -/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152). +/// HTTP: E7M4-08 (NEO-151). Economy validation: E7M4-07 (NEO-150). Telemetry hook sites: E7M4-09 (NEO-152). /// public static class ContractGeneratorOperations { @@ -17,6 +20,10 @@ public static class ContractGeneratorOperations string seedBucket, int? zoneDifficultyBand, IContractTemplateRegistry templateRegistry, + IEncounterDefinitionRegistry encounterRegistry, + IItemDefinitionRegistry itemRegistry, + ISkillDefinitionRegistry skillRegistry, + IFactionDefinitionRegistry factionRegistry, IContractInstanceStore instanceStore, IFactionStandingStore standingStore, TimeProvider timeProvider) @@ -52,6 +59,17 @@ public static class ContractGeneratorOperations return Deny(denyReason!); } + if (!ContractEconomyValidation.TryValidateTemplate( + template!, + encounterRegistry, + itemRegistry, + skillRegistry, + factionRegistry, + out var economyReason)) + { + return Deny(economyReason!); + } + var instanceId = ContractInstanceIds.MakeDeterministicInstanceId( normalizedPlayerId, template!.Id, diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs index 67be3ba..b4fc34a 100644 --- a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs @@ -12,4 +12,8 @@ public static class ContractGeneratorReasonCodes public const string NoEligibleTemplate = "no_eligible_template"; public const string UnknownTemplate = "unknown_template"; + + public const string EconomyCapExceeded = "economy_cap_exceeded"; + + public const string InvalidRewardBundle = "invalid_reward_bundle"; } diff --git a/server/README.md b/server/README.md index 2d45829..57d5db3 100644 --- a/server/README.md +++ b/server/README.md @@ -298,6 +298,8 @@ Completed instances are immutable (no status regression). Player, template, and - **Omitted/empty `templateId`:** first template from **`GetDefinitionsInIdOrder()`** matching band + standing. - Faction floor uses **`FactionGateOperations`** on the template **`minFactionStanding`** row. +**Economy validation (NEO-150):** after template selection, **`ContractEconomyValidation.TryValidateTemplate`** re-checks band caps and reward-bundle cross-refs against live encounter/item/skill/faction registries (same rules as startup catalog load). Fail-closed before persist. + **Deterministic instance id:** **`ContractInstanceIds.MakeDeterministicInstanceId`** — SHA-256 of `{playerId}|{templateId}|{seedBucket}` (normalized) → **`ci_{32 lowercase hex}`**. **Reason codes:** @@ -309,10 +311,12 @@ Completed instances are immutable (no status regression). Player, template, and | **`active_contract_exists`** | Player already has an active instance | | **`unknown_template`** | Explicit template id not in registry | | **`no_eligible_template`** | No template matches band + standing filters | +| **`economy_cap_exceeded`** | Item qty, skill XP, or rep grant exceeds band policy | +| **`invalid_reward_bundle`** | Unknown encounter/item/skill/faction ref or disallowed skill XP source | **Result:** **`ContractIssueOperationResult`** — `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, **`encounterTemplateId`** from the selected template. -Economy band-cap lint at issue time lands in **NEO-150** (E7M4-07). HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md). +HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md), [NEO-150 implementation plan](../../docs/plans/NEO-150-implementation-plan.md). ## Contract completion operations (NEO-149)