diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index 61e8ff4..bfc7d6d 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -57,6 +57,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies. **Runtime stores (NEO-146):** per-player **`IContractInstanceStore`** (one active instance) + append-only **`IContractOutcomeStore`**; [server README — Contract instance store](../../../server/README.md#contract-instance-store-neo-146). +**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). + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 6543e94..27136a5 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/`. **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), 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). **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), 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 32b2806..8f9441e 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)). 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)). 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-147-implementation-plan.md b/docs/plans/NEO-147-implementation-plan.md new file mode 100644 index 0000000..ae8f14d --- /dev/null +++ b/docs/plans/NEO-147-implementation-plan.md @@ -0,0 +1,204 @@ +# NEO-147 — E7M4-04: ContractGeneratorOperations.TryIssue + +**Linear:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) +**Branch:** `NEO-147-e7m4-04-contract-generator-try-issue` +**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-04** +**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) +**Pattern:** [NEO-117-implementation-plan.md](NEO-117-implementation-plan.md) (`QuestStateOperations` orchestrator + reason codes); [NEO-146-implementation-plan.md](NEO-146-implementation-plan.md) (instance store primitives); [NEO-145-implementation-plan.md](NEO-145-implementation-plan.md) (`IContractTemplateRegistry`) +**Precursor:** [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) **`Done`** — `IContractInstanceStore`, `IContractOutcomeStore` (**on `main`**) +**Blocks:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149) (E7M4-06 completion wiring), [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) (E7M4-07 economy lint at issue) +**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) (blocked by E7M4-08 HTTP — not this story) + +## Goal + +Issue a validated **`active`** contract instance from **`ContractSeed`** inputs + player context via **`ContractGeneratorOperations.TryIssue`**. Template selection respects zone difficulty band and faction standing floor; persist through **`IContractInstanceStore.TryCreateActive`**. + +## Kickoff clarifications + +**No blocking decisions needed.** Linear AC, [E7M4-04 backlog scope](E7M4-pre-production-backlog.md#e7m4-04--contractgeneratoroperationstryissue), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and NEO-117/NEO-146 precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| Economy validation at issue | **Out of scope** — lands in E7M4-07 / NEO-150 | Backlog E7M4-04 out-of-scope; NEO-145 plan defers issue-time band-cap enforcement | +| HTTP / Godot | **Out of scope** — E7M4-08 / NEO-151 | Backlog E7M4-04 | +| Encounter completion wiring | **Out of scope** — E7M4-06 / NEO-149 | Backlog E7M4-04 | +| Optional `templateId` | **Operations layer accepts null/empty** — auto-select from band + standing filters | Backlog inputs list; HTTP schema (`contract-seed.schema.json`) keeps required `templateId` for POST body in NEO-151 | +| `zoneDifficultyBand` default | **Default 1** when omitted | Backlog + kickoff table “prototype defaults to 1” | +| Template selection (multi-match) | **First by `GetDefinitionsInIdOrder()`** among eligible rows | Deterministic with future multi-template catalogs; prototype has one row | +| Faction filter | **Reuse `FactionGateOperations.TryEvaluate`** on template `MinFactionStanding` (single rule → one-element list) | Quest accept precedent (NEO-137); template row uses `FactionGateRuleRow` | +| Active cap deny | **`active_contract_exists`** before template selection | Backlog AC + store reason code already defined | +| Unknown explicit template | **`unknown_template`** when id not in registry | Backlog “unknown template fail-closed” | +| No eligible template | **`no_eligible_template`** when filter yields zero rows (band/standing mismatch) | Backlog template selection | +| Deterministic instance id | **`ContractInstanceIds.MakeDeterministicInstanceId`** — SHA-256 over `{playerId}|{templateId}|{seedBucket}` (normalized), format **`ci_{32 hex}`** lowercase | Backlog seed/determinism row; ids match `[a-z][a-z0-9_]*` store normalization | +| Result shape | **`ContractIssueOperationResult`** — `success`, `reasonCode`, `ContractInstanceState?` snapshot, **`EncounterTemplateId`** from selected template | AC “correct encounter binding”; HTTP projection in NEO-151 can map snapshot + encounter | +| Telemetry | **Comment-only hook site** deferred to E7M4-09 / NEO-152 | Backlog E7M4-09 lists `contract_issued` in TryIssue | +| Store boundary | **Orchestrator validates template + gates**; store persists ids only (NEO-146 precedent) | README contract instance store section | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- **`ContractGeneratorOperations.TryIssue`** static orchestrator in `Game/Contracts/`. +- **`ContractGeneratorReasonCodes`** + **`ContractIssueOperationResult`** envelope. +- **`ContractInstanceIds.MakeDeterministicInstanceId`** helper. +- Inputs: `playerId`, optional `templateId`, `seedBucket`, optional `zoneDifficultyBand` (default **1**), injected registries/stores + `TimeProvider`. +- Template selection: band match + faction standing floor; structured deny paths. +- **`TryCreateActive`** persist with deterministic instance id + `issuedAt` from `TimeProvider`. +- Unit tests (AAA): happy issue, second issue while active deny, unknown template, no eligible template (band mismatch), player not writable, invalid ids, deterministic id stability. +- Integration test via **`InMemoryWebApplicationFactory`**: full DI path issue → active snapshot with **`prototype_combat_pocket`** encounter binding. +- Optional Postgres integration test (`RequirePostgresFact`): issue persists cross-factory (mirror NEO-146 persistence tests). +- `server/README.md` contract generator operations section. + +**Out of scope (from Linear + backlog):** + +- **`POST …/contracts/issue`** HTTP + DTOs (NEO-151 / E7M4-08). +- **`ContractEconomyValidation`** at issue (NEO-150 / E7M4-07). +- **`ContractCompletionOperations`** + encounter wiring (NEO-149 / E7M4-06). +- **`RewardRouterOperations.TryDeliverContractCompletion`** (NEO-148 / E7M4-05). +- Godot client (NEO-153+). +- Telemetry comment hooks (NEO-152 / E7M4-09). + +**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — server HTTP must land in NEO-151 first. + +## Acceptance criteria checklist + +- [x] Issue creates **`active`** instance with correct encounter binding. +- [x] Second issue while active returns structured deny. +- [x] `dotnet test` covers generator gates. + +## Implementation reconciliation (shipped) + +- **Types:** `ContractGeneratorReasonCodes`, `ContractIssueOperationResult`, `ContractInstanceIds.MakeDeterministicInstanceId`. +- **Orchestrator:** `ContractGeneratorOperations.TryIssue` — band + faction template selection, deterministic id, store persist. +- **Tests:** `ContractGeneratorOperationsTests` (14 unit), `ContractGeneratorOperationsIntegrationTests` (DI), `ContractGeneratorPersistenceIntegrationTests` (`RequirePostgresFact`); **891** tests green. +- **Docs:** `server/README.md` contract generator section; `E7_M4_ContractMissionGenerator.md` NEO-147 note. + +## Technical approach + +### 1. Types (`Game/Contracts/`) + +| Type | Role | +|------|------| +| `ContractGeneratorReasonCodes` | `player_not_writable`, `active_contract_exists`, `no_eligible_template`, `unknown_template`, `invalid_ids` | +| `ContractIssueOperationResult` | `Success`, `ReasonCode?`, `ContractInstanceState?` Snapshot, `EncounterTemplateId?` | +| `ContractInstanceIds.MakeDeterministicInstanceId` | SHA-256 deterministic id (see kickoff table) | + +Reuse **`ContractInstanceReasonCodes.ActiveContractExists`** string value in generator denials for consistency (`active_contract_exists`). + +### 2. `ContractGeneratorOperations.TryIssue` + +Signature (conceptual): + +```csharp +public static ContractIssueOperationResult TryIssue( + string playerId, + string? templateId, + string seedBucket, + int? zoneDifficultyBand, + IContractTemplateRegistry templateRegistry, + IContractInstanceStore instanceStore, + IFactionStandingStore standingStore, + TimeProvider timeProvider) +``` + +**Flow:** + +1. Normalize `playerId`, `seedBucket`; resolve band = `zoneDifficultyBand ?? 1`. +2. Deny **`invalid_ids`** when player or seed bucket empty after normalize. +3. Deny **`player_not_writable`** when `!instanceStore.CanWritePlayer(playerId)`. +4. Deny **`active_contract_exists`** when `instanceStore.TryGetActiveForPlayer` returns true (include existing snapshot in result when helpful for tests — mirror quest `AlreadyActive`). +5. **Resolve template:** + - **Explicit `templateId`:** `TryGetDefinition` → **`unknown_template`** if missing; else require `ZoneDifficultyBand == band` and `FactionGateOperations.TryEvaluate` passes on `[MinFactionStanding]` → else **`no_eligible_template`**. + - **Omitted/empty `templateId`:** filter `GetDefinitionsInIdOrder()` where band matches and faction gate passes; zero rows → **`no_eligible_template`**; pick first. +6. `instanceId = MakeDeterministicInstanceId(playerId, template.Id, seedBucket)`; deny **`invalid_ids`** if empty. +7. `instanceStore.TryCreateActive(playerId, instanceId, template.Id, seedBucket, timeProvider.GetUtcNow(), out snapshot)` — expect `true`; on false (race), re-read active and deny **`active_contract_exists`**. +8. Return success with snapshot + `template.EncounterTemplateId`. + +No outcome-store writes in E7M4-04. + +### 3. Template selection helpers (private static in same file) + +- `TrySelectTemplate(...)` — keeps `TryIssue` readable; unit-test selection indirectly through public API. +- Prototype happy path: band **1**, open standing **0**, single template **`prototype_contract_clear_combat_pocket`** → encounter **`prototype_combat_pocket`**. + +### 4. Tests + +**`ContractGeneratorOperationsTests`** (in-memory deps, no host): + +| Test | Covers | +|------|--------| +| Happy issue | Active snapshot, template id, seed bucket, encounter binding | +| Deterministic id | Same inputs → same `contractInstanceId`; different seed bucket → different id | +| Second issue while active | `active_contract_exists`, snapshot unchanged | +| Issue after complete | New instance allowed when no active row (complete via store, then issue) | +| Unknown explicit template | `unknown_template` | +| Band mismatch | `no_eligible_template` (request band 99) | +| Player not writable | `player_not_writable` | +| Empty player / seed bucket | `invalid_ids` | + +**`ContractGeneratorOperationsIntegrationTests`** (optional single `InMemoryWebApplicationFactory` test): + +- Resolve stores + registry from DI; issue for dev player; assert encounter id. + +**`ContractGeneratorPersistenceIntegrationTests`** (`RequirePostgresFact`, optional): + +- Factory A issues; factory B reads active instance by player. + +**Host smoke:** extend or add `Host_ShouldIssueContractViaGeneratorOperations` if useful; not required if integration test covers DI. + +### 5. Docs + +- **`server/README.md`** — new **Contract generator operations (NEO-147)** section: inputs, selection rules, reason codes, deterministic id shape, orchestrator-only (no HTTP). +- One-line note in **`E7_M4_ContractMissionGenerator.md`** runtime paragraph when implementation lands. + +### 6. Implementation order + +1. `ContractGeneratorReasonCodes`, `ContractIssueOperationResult`, `MakeDeterministicInstanceId`. +2. `ContractGeneratorOperations.TryIssue` + selection helpers. +3. Unit tests → integration test → optional Postgres test. +4. `server/README.md`. +5. Run `dotnet test`. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-147-implementation-plan.md` | This plan | +| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs` | Stable generator deny reason strings | +| `server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs` | Issue operation envelope | +| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs` | `TryIssue` orchestrator | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs` | AAA unit tests for issue/deny paths | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs` | DI integration test via test host | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs` | Postgres cross-factory issue read (`RequirePostgresFact`) | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs` | Add `MakeDeterministicInstanceId` | +| `server/README.md` | Document contract generator operations | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | Note NEO-147 generator operations landed (post-implementation) | + +## Tests + +| Layer | What | +|-------|------| +| `ContractGeneratorOperationsTests` | Happy issue, encounter binding, deterministic id, active deny, post-complete re-issue, unknown template, band mismatch, writable gate, invalid ids | +| `ContractGeneratorOperationsIntegrationTests` | Full DI path with loaded prototype catalog | +| `ContractGeneratorPersistenceIntegrationTests` | Optional Postgres cross-factory issue read (`RequirePostgresFact`) | +| CI | Existing `dotnet test`; no Python/content changes expected | + +Manual Godot QA: **none** (server orchestrator; capstone NEO-154). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Explicit template fails band/standing vs `unknown_template` | Use **`no_eligible_template`** (template known but ineligible); reserve **`unknown_template`** for missing registry id | `adopted` | +| Re-issue same seed after complete → same instance id collision | **`TryCreateActive`** denies duplicate instance id; orchestrator should treat as success-with-existing or deny — **deny duplicate id**, caller must rotate `seedBucket` for new contract | `adopted` | +| Economy caps slipped past CI | Accept for E7M4-04; NEO-150 adds issue-time lint | `deferred` | +| Telemetry `contract_issued` comment | Defer to NEO-152; no hook in E7M4-04 unless zero-cost comment added | `deferred` | + +## Client counterpart + +Server-only orchestrator. Playable issue UI: **NEO-153** after **NEO-151** HTTP. diff --git a/docs/reviews/2026-06-27-NEO-147.md b/docs/reviews/2026-06-27-NEO-147.md new file mode 100644 index 0000000..d12007b --- /dev/null +++ b/docs/reviews/2026-06-27-NEO-147.md @@ -0,0 +1,63 @@ +# Code review — NEO-147 (E7M4-04 contract generator TryIssue) + +**Date:** 2026-06-27 +**Scope:** Branch `NEO-147-e7m4-04-contract-generator-try-issue` — commits `7f62f43`…`c37c3c7` (5 commits) +**Base:** `main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-147 adds `ContractGeneratorOperations.TryIssue`, a server-authoritative orchestrator that selects a contract template (explicit id or auto-select by band + faction standing), mints a deterministic instance id (`ci_{32 hex}`), and persists via `IContractInstanceStore.TryCreateActive`. Reason codes align with NEO-146 store codes where shared; generator-specific codes cover unknown template and no eligible template. The flow mirrors `QuestStateOperations` / NEO-117 patterns: early player/active gates, delegated faction evaluation, structured deny envelopes with optional snapshots, and race-safe create-failure handling. Eleven AAA unit tests, one DI integration test, and one optional Postgres persistence test cover happy paths and primary deny gates; `dotnet test` passes **888** tests. Risk is low — orchestrator-only, no HTTP or client surface; downstream NEO-149/NEO-151 consume this layer. + +## Documentation checked + +| Document | Alignment | +|----------|-----------| +| `docs/plans/NEO-147-implementation-plan.md` | **Matches** — AC checklist complete; reconciliation reflects shipped types, orchestrator, tests (11 unit + DI + Postgres), README, module note. Minor drift: plan “Files to add” omits `ContractGeneratorPersistenceIntegrationTests.cs` (listed under Tests section only). | +| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-04) | **Matches** — template selection, active cap, deterministic ids, structured denies; economy lint, HTTP, completion wiring correctly out of scope. | +| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-147 generator operations paragraph + README link added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches** — E7.M4 row stops at E7M4-03/NEO-146; should add E7M4-04/NEO-147 generator ops after merge (same follow-up pattern as NEO-146).~~ **Done.** — E7M4-04 / NEO-147 generator ops added to E7.M4 row. | +| `docs/decomposition/modules/module_dependency_register.md` | ~~**Partially matches** — E7.M4 note ends at NEO-146; optional one-line NEO-147 after merge.~~ **Done.** — E7M4-04 / NEO-147 note added. | +| `docs/decomposition/modules/client_server_authority.md` | **N/A** — server orchestrator; authority unchanged. | +| Full-stack epic decomposition | **N/A** — server-only; client counterpart NEO-153 correctly deferred until NEO-151 HTTP. | + +~~Register/tracking tables should be updated for E7M4-04 after merge — **should fix** (non-blocking), mirroring NEO-146 alignment follow-up.~~ **Done.** + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Update decomposition tracking for E7M4-04** — After merge, extend `documentation_and_implementation_alignment.md` E7.M4 row (and optionally `module_dependency_register.md`) with E7M4-04: `ContractGeneratorOperations.TryIssue`, reason codes, deterministic ids, README section — same pattern as NEO-145/NEO-146.~~ **Done.** + +2. ~~**Faction standing deny path** — Plan and README document faction floor via `FactionGateOperations`; unit tests cover band mismatch but not standing failure → `no_eligible_template`. Prototype template min standing is **0** (always passes with default standing), so add a unit test with a synthetic template row (`minStanding: 1`) and zero player standing to lock the orchestrator wiring (distinct from `FactionGateOperationsTests`).~~ **Done.** — `TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin`. + +3. ~~**Duplicate instance id after complete** — Plan adopted: re-issue with the same `seedBucket` after completion hits store duplicate-id deny; orchestrator maps to `invalid_ids` via `DenyCreateFailure`. Add a unit test (complete first issue, re-issue same seed) to document caller contract (“rotate `seedBucket` for a new contract”).~~ **Done.** — `TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete`. + +## Nits + +- ~~Nit: `MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket` has an empty `// Act` block — move equality assertions into Act or fold into Arrange per AAA convention.~~ **Done.** + +- ~~Nit: `ContractGeneratorPersistenceIntegrationTests` mixes second-factory read into Act; acceptable but slightly blurs Act vs Assert compared to other integration tests.~~ **Done.** — cross-factory read moved to Assert. + +- Nit: `DenyCreateFailure` fallback `invalid_ids` for duplicate completed instance id is correct per plan but coarse for future HTTP mappers — consider a dedicated reason code if NEO-151 needs clearer client messaging (defer unless HTTP design requires it). + +## Verification + +```bash +cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln +cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractGenerator" +``` + +Optional (Postgres configured): + +```bash +export ConnectionStrings__NeonSprawl='…' +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractGeneratorPersistence" +``` + +Manual Godot QA: **none** (server orchestrator per plan). diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs new file mode 100644 index 0000000..7d20519 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class ContractGeneratorOperationsIntegrationTests +{ + private const string PlayerId = "dev-local-1"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string EncounterId = "prototype_combat_pocket"; + private const string SeedBucket = "2026-06-22"; + + [Fact] + public async Task TryIssue_ShouldCreateActiveInstanceWithEncounterBinding_WhenResolvedFromDi() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + using var scope = factory.Services.CreateScope(); + var templateRegistry = scope.ServiceProvider.GetRequiredService(); + var instanceStore = scope.ServiceProvider.GetRequiredService(); + var standingStore = scope.ServiceProvider.GetRequiredService(); + var timeProvider = scope.ServiceProvider.GetRequiredService(); + var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId( + PlayerId, + TemplateId, + SeedBucket); + // Act + var result = ContractGeneratorOperations.TryIssue( + PlayerId, + TemplateId, + SeedBucket, + zoneDifficultyBand: null, + templateRegistry, + instanceStore, + standingStore, + timeProvider); + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.NotNull(result.Snapshot); + Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId); + Assert.Equal(TemplateId, result.Snapshot.TemplateId); + Assert.Equal(EncounterId, result.EncounterTemplateId); + Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status); + Assert.True(instanceStore.TryGetActiveForPlayer(PlayerId, out var active)); + Assert.Equal(expectedInstanceId, active.ContractInstanceId); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs new file mode 100644 index 0000000..32e53d3 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs @@ -0,0 +1,351 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +public sealed class ContractGeneratorOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string UnknownPlayerId = "unknown-player-xyz"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string EncounterId = "prototype_combat_pocket"; + private const string GridOperatorsFactionId = "prototype_faction_grid_operators"; + private const string GatedTemplateId = "test_gated_contract_template"; + private const string SeedBucket = "2026-06-22"; + private const string AlternateSeedBucket = "2026-06-23"; + private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void TryIssue_ShouldCreateActiveInstance_WhenTemplateEligible() + { + // Arrange + var deps = CreateDependencies(); + var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId( + PlayerId, + TemplateId, + SeedBucket); + // Act + var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.NotNull(result.Snapshot); + Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId); + Assert.Equal(TemplateId, result.Snapshot.TemplateId); + Assert.Equal(PlayerId, result.Snapshot.PlayerId); + Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status); + Assert.Equal(SeedBucket, result.Snapshot.SeedBucket); + Assert.Equal(IssuedAt, result.Snapshot.IssuedAt); + Assert.Equal(EncounterId, result.EncounterTemplateId); + Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active)); + Assert.Equal(expectedInstanceId, active.ContractInstanceId); + } + + [Fact] + public void TryIssue_ShouldAutoSelectTemplate_WhenTemplateIdOmitted() + { + // Arrange + var deps = CreateDependencies(); + // Act + var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket); + // Assert + Assert.True(result.Success); + Assert.Equal(TemplateId, result.Snapshot!.TemplateId); + Assert.Equal(EncounterId, result.EncounterTemplateId); + } + + [Fact] + public void MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket() + { + // Arrange + // Act + var first = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket); + var same = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket); + var different = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, AlternateSeedBucket); + // Assert + Assert.Equal(first, same); + Assert.NotEqual(first, different); + Assert.StartsWith("ci_", first, StringComparison.Ordinal); + } + + [Fact] + public void TryIssue_ShouldDenyActiveContractExists_WhenPlayerAlreadyHasActiveInstance() + { + // Arrange + var deps = CreateDependencies(); + var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); + Assert.True(first.Success); + // Act + var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket); + // Assert + Assert.False(second.Success); + Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, second.ReasonCode); + Assert.NotNull(second.Snapshot); + Assert.Equal(first.Snapshot!.ContractInstanceId, second.Snapshot!.ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status); + Assert.Null(second.EncounterTemplateId); + } + + [Fact] + public void TryIssue_ShouldAllowNewIssue_WhenPriorInstanceCompleted() + { + // Arrange + var deps = CreateDependencies(); + var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); + Assert.True(first.Success); + Assert.True(deps.InstanceStore.TryMarkComplete( + PlayerId, + first.Snapshot!.ContractInstanceId, + CompletedAt, + out _)); + // Act + var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket); + // Assert + Assert.True(second.Success); + Assert.NotEqual(first.Snapshot.ContractInstanceId, second.Snapshot!.ContractInstanceId); + Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status); + Assert.Equal(EncounterId, second.EncounterTemplateId); + } + + [Fact] + public void TryIssue_ShouldDenyUnknownTemplate_WhenExplicitTemplateMissing() + { + // Arrange + var deps = CreateDependencies(); + // Act + var result = TryIssue(deps, templateId: "not_a_real_template", seedBucket: SeedBucket); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.UnknownTemplate, result.ReasonCode); + Assert.Null(result.Snapshot); + Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); + } + + [Fact] + public void TryIssue_ShouldDenyNoEligibleTemplate_WhenBandMismatch() + { + // Arrange + var deps = CreateDependencies(); + // Act + var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket, zoneDifficultyBand: 99); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode); + Assert.Null(result.Snapshot); + } + + [Fact] + public void TryIssue_ShouldDenyNoEligibleTemplate_WhenAutoSelectBandHasNoTemplates() + { + // Arrange + var deps = CreateDependencies(); + // Act + var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket, zoneDifficultyBand: 99); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode); + } + + [Fact] + public void TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin() + { + // Arrange + var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId]; + var gatedTemplate = prototype with + { + Id = GatedTemplateId, + MinFactionStanding = new FactionGateRuleRow(GridOperatorsFactionId, 1), + }; + var deps = CreateDependencies(gatedTemplate); + // Act + var result = TryIssue(deps, templateId: GatedTemplateId, seedBucket: SeedBucket); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode); + Assert.Null(result.Snapshot); + Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); + } + + [Fact] + public void TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete() + { + // Arrange + var deps = CreateDependencies(); + var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); + Assert.True(first.Success); + Assert.True(deps.InstanceStore.TryMarkComplete( + PlayerId, + first.Snapshot!.ContractInstanceId, + CompletedAt, + out _)); + // Act + var second = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); + // Assert + Assert.False(second.Success); + Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, second.ReasonCode); + Assert.NotNull(second.Snapshot); + Assert.Equal(ContractInstanceStatus.Completed, second.Snapshot!.Status); + Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); + } + + [Fact] + public void TryIssue_ShouldDenyInvalidIds_WhenCreateActiveReturnsFalseWithoutSnapshot() + { + // Arrange + var deps = CreateDependencies(); + var store = new CreateActiveNullSnapshotStore(deps.InstanceStore); + var templateRegistry = deps.TemplateRegistry; + var standingStore = deps.StandingStore; + // Act + var result = ContractGeneratorOperations.TryIssue( + PlayerId, + TemplateId, + SeedBucket, + zoneDifficultyBand: null, + templateRegistry, + store, + standingStore, + deps.TimeProvider); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode); + Assert.Null(result.Snapshot); + } + + [Fact] + public void TryIssue_ShouldDenyPlayerNotWritable_WhenPlayerUnknown() + { + // Arrange + var deps = CreateDependencies(); + // Act + var result = TryIssue(deps, playerId: UnknownPlayerId, templateId: TemplateId, seedBucket: SeedBucket); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.PlayerNotWritable, result.ReasonCode); + Assert.Null(result.Snapshot); + } + + [Fact] + public void TryIssue_ShouldDenyInvalidIds_WhenPlayerIdEmpty() + { + // Arrange + var deps = CreateDependencies(); + // Act + var result = TryIssue(deps, playerId: " ", templateId: TemplateId, seedBucket: SeedBucket); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode); + } + + [Fact] + public void TryIssue_ShouldDenyInvalidIds_WhenSeedBucketEmpty() + { + // Arrange + var deps = CreateDependencies(); + // Act + var result = TryIssue(deps, templateId: TemplateId, seedBucket: " "); + // Assert + Assert.False(result.Success); + Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode); + } + + private static ContractIssueOperationResult TryIssue( + GeneratorTestDependencies deps, + string? playerId = PlayerId, + string? templateId = TemplateId, + string seedBucket = SeedBucket, + int? zoneDifficultyBand = null) => + ContractGeneratorOperations.TryIssue( + playerId!, + templateId, + seedBucket, + zoneDifficultyBand, + deps.TemplateRegistry, + deps.InstanceStore, + deps.StandingStore, + deps.TimeProvider); + + private static GeneratorTestDependencies CreateDependencies(params ContractTemplateRow[] templates) + { + var rows = templates.Length == 0 + ? new Dictionary(StringComparer.Ordinal) + { + [TemplateId] = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId], + } + : templates.ToDictionary(static t => t.Id, StringComparer.Ordinal); + var catalog = new ContractTemplateCatalog("/tmp/catalog", rows, catalogJsonFileCount: 1); + var templateRegistry = new ContractTemplateRegistry(catalog); + var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); + var instanceStore = new InMemoryContractInstanceStore(positionOptions); + var standingStore = CreateStandingStore(); + return new GeneratorTestDependencies( + templateRegistry, + instanceStore, + standingStore, + new FakeTimeProvider(IssuedAt)); + } + + private static InMemoryFactionStandingStore CreateStandingStore() + { + var byId = new Dictionary(StringComparer.Ordinal) + { + [GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId], + }; + var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); + var registry = new FactionDefinitionRegistry(catalog); + var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); + return new InMemoryFactionStandingStore(options, registry); + } + + private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } + + private readonly record struct GeneratorTestDependencies( + IContractTemplateRegistry TemplateRegistry, + IContractInstanceStore InstanceStore, + IFactionStandingStore StandingStore, + TimeProvider TimeProvider); + + /// Simulates store create failure without populating the out snapshot (Bugbot regression). + private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore + { + public bool CanWritePlayer(string playerId) => inner.CanWritePlayer(playerId); + + public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) => + inner.TryGet(playerId, contractInstanceId, out snapshot); + + public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) => + inner.TryGetActiveForPlayer(playerId, out snapshot); + + public bool TryCreateActive( + string playerId, + string contractInstanceId, + string templateId, + string seedBucket, + DateTimeOffset issuedAt, + out ContractInstanceState snapshot) + { + snapshot = null!; + return false; + } + + public bool TryMarkComplete( + string playerId, + string contractInstanceId, + DateTimeOffset completedAt, + out ContractInstanceState snapshot) => + inner.TryMarkComplete(playerId, contractInstanceId, completedAt, out snapshot); + + public bool TryClearInstance(string playerId, string contractInstanceId) => + inner.TryClearInstance(playerId, contractInstanceId); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs new file mode 100644 index 0000000..cdc7a2c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Contracts; +using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests.Game.PositionState; +using Npgsql; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Contracts; + +[Collection("Postgres integration")] +public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegrationHarness harness) +{ + private PostgresWebApplicationFactory Factory => harness.Factory; + + private const string PlayerId = "dev-local-1"; + private const string TemplateId = "prototype_contract_clear_combat_pocket"; + private const string EncounterId = "prototype_combat_pocket"; + private const string SeedBucket = "2026-06-22"; + + [RequirePostgresFact] + public async Task TryIssue_ShouldPersistActiveInstanceAcrossNewFactory() + { + // Arrange + await ResetContractInstanceTableAsync(); + var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId( + PlayerId, + TemplateId, + SeedBucket); + + // Act + ContractIssueOperationResult issued; + using (var firstScope = Factory.Services.CreateScope()) + { + var services = firstScope.ServiceProvider; + issued = ContractGeneratorOperations.TryIssue( + PlayerId, + TemplateId, + SeedBucket, + zoneDifficultyBand: null, + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + + // Assert + Assert.True(issued.Success); + Assert.Equal(expectedInstanceId, issued.Snapshot!.ContractInstanceId); + Assert.Equal(EncounterId, issued.EncounterTemplateId); + + await using var secondFactory = new PostgresWebApplicationFactory(); + using var secondScope = secondFactory.Services.CreateScope(); + var readStore = secondScope.ServiceProvider.GetRequiredService(); + Assert.True(readStore.TryGetActiveForPlayer(PlayerId, out var readBack)); + Assert.Equal(expectedInstanceId, readBack.ContractInstanceId); + Assert.Equal(TemplateId, readBack.TemplateId); + Assert.Equal(ContractInstanceStatus.Active, readBack.Status); + Assert.Equal(SeedBucket, readBack.SeedBucket); + } + + private async Task ResetContractInstanceTableAsync() + { + var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); + if (string.IsNullOrWhiteSpace(cs)) + { + throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); + } + + _ = Factory.Services; + var options = Factory.Services.GetRequiredService>().Value; + + var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); + var instanceDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V011__contract_instance.sql"); + if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath)) + { + throw new FileNotFoundException("Test DDL for contract generator persistence not found."); + } + + var positionDdl = await File.ReadAllTextAsync(positionDdlPath); + var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath); + await using var conn = new NpgsqlConnection(cs); + await conn.OpenAsync(); + await using var applyPosition = new NpgsqlCommand(positionDdl, conn); + await applyPosition.ExecuteNonQueryAsync(); + + await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn); + await truncate.ExecuteNonQueryAsync(); + + PostgresPositionBootstrap.SeedDevPlayer(conn, options); + + await using var applyInstance = new NpgsqlCommand(instanceDdl, conn); + await applyInstance.ExecuteNonQueryAsync(); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs index a1b1a2b..2d235df 100644 --- a/server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Factions/FactionGateOperationsTests.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; -using NeonSprawl.Server.Tests; using Xunit; namespace NeonSprawl.Server.Tests.Game.Factions; @@ -12,6 +11,7 @@ public sealed class FactionGateOperationsTests { private const string PlayerId = "dev-local-1"; private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + private const string RustCollectiveFactionId = "prototype_faction_rust_collective"; private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding; [Fact] @@ -63,19 +63,18 @@ public sealed class FactionGateOperationsTests { // Arrange var standingStore = CreateStandingStore(); - const string rustFactionId = "prototype_faction_rust_collective"; Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success); var rules = new[] { new FactionGateRuleRow(GridFactionId, GridMinStanding), - new FactionGateRuleRow(rustFactionId, 1), + new FactionGateRuleRow(RustCollectiveFactionId, 1), }; // Act var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore); // Assert Assert.False(outcome.Success); Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode); - Assert.Equal(rustFactionId, outcome.FailingFactionId); + Assert.Equal(RustCollectiveFactionId, outcome.FailingFactionId); Assert.Equal(1, outcome.RequiredMinStanding); Assert.Equal(0, outcome.CurrentStanding); } @@ -120,12 +119,10 @@ public sealed class FactionGateOperationsTests private static FactionDefinitionRegistry CreatePrototypeRegistry() { - var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; - var rustFactionId = "prototype_faction_rust_collective"; var byId = new Dictionary(StringComparer.Ordinal) { - [gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId], - [rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId], + [GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId], + [RustCollectiveFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustCollectiveFactionId], }; var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); return new FactionDefinitionRegistry(catalog); diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs new file mode 100644 index 0000000..ba158b9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs @@ -0,0 +1,169 @@ +using NeonSprawl.Server.Game.Factions; + +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). +/// +public static class ContractGeneratorOperations +{ + /// + /// Issues an active contract instance when template selection and store gates pass. + /// + public static ContractIssueOperationResult TryIssue( + string playerId, + string? templateId, + string seedBucket, + int? zoneDifficultyBand, + IContractTemplateRegistry templateRegistry, + IContractInstanceStore instanceStore, + IFactionStandingStore standingStore, + TimeProvider timeProvider) + { + var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId); + var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(seedBucket); + var band = zoneDifficultyBand ?? 1; + + if (normalizedPlayerId.Length == 0 || normalizedSeedBucket.Length == 0) + { + return Deny(ContractGeneratorReasonCodes.InvalidIds); + } + + if (!instanceStore.CanWritePlayer(normalizedPlayerId)) + { + return Deny(ContractGeneratorReasonCodes.PlayerNotWritable); + } + + if (instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot)) + { + return Deny(ContractGeneratorReasonCodes.ActiveContractExists, activeSnapshot); + } + + if (!TrySelectTemplate( + templateId, + band, + normalizedPlayerId, + templateRegistry, + standingStore, + out var template, + out var denyReason)) + { + return Deny(denyReason!); + } + + var instanceId = ContractInstanceIds.MakeDeterministicInstanceId( + normalizedPlayerId, + template!.Id, + normalizedSeedBucket); + if (instanceId.Length == 0) + { + return Deny(ContractGeneratorReasonCodes.InvalidIds); + } + + if (instanceStore.TryCreateActive( + normalizedPlayerId, + instanceId, + template.Id, + normalizedSeedBucket, + timeProvider.GetUtcNow(), + out var snapshot)) + { + return Success(snapshot, template.EncounterTemplateId); + } + + return DenyCreateFailure(instanceStore, normalizedPlayerId, snapshot); + } + + private static bool TrySelectTemplate( + string? templateId, + int band, + string normalizedPlayerId, + IContractTemplateRegistry templateRegistry, + IFactionStandingStore standingStore, + out ContractTemplateRow? template, + out string? denyReason) + { + template = null; + denyReason = null; + + var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(templateId); + if (normalizedTemplateId.Length > 0) + { + if (!templateRegistry.TryGetDefinition(normalizedTemplateId, out template)) + { + denyReason = ContractGeneratorReasonCodes.UnknownTemplate; + return false; + } + + if (!IsEligible(template, band, normalizedPlayerId, standingStore)) + { + denyReason = ContractGeneratorReasonCodes.NoEligibleTemplate; + template = null; + return false; + } + + return true; + } + + foreach (var candidate in templateRegistry.GetDefinitionsInIdOrder()) + { + if (!IsEligible(candidate, band, normalizedPlayerId, standingStore)) + { + continue; + } + + template = candidate; + return true; + } + + denyReason = ContractGeneratorReasonCodes.NoEligibleTemplate; + return false; + } + + private static bool IsEligible( + ContractTemplateRow template, + int band, + string normalizedPlayerId, + IFactionStandingStore standingStore) + { + if (template.ZoneDifficultyBand != band) + { + return false; + } + + var gate = FactionGateOperations.TryEvaluate( + normalizedPlayerId, + [template.MinFactionStanding], + standingStore); + return gate.Success; + } + + private static ContractIssueOperationResult DenyCreateFailure( + IContractInstanceStore instanceStore, + string normalizedPlayerId, + ContractInstanceState? attemptSnapshot) + { + if (instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var active)) + { + return Deny(ContractGeneratorReasonCodes.ActiveContractExists, active); + } + + if (attemptSnapshot is { Status: ContractInstanceStatus.Active }) + { + return Deny(ContractGeneratorReasonCodes.ActiveContractExists, attemptSnapshot); + } + + return Deny(ContractGeneratorReasonCodes.InvalidIds, attemptSnapshot); + } + + private static ContractIssueOperationResult Success( + ContractInstanceState snapshot, + string encounterTemplateId) => + new(true, null, snapshot, encounterTemplateId); + + private static ContractIssueOperationResult Deny( + string reasonCode, + ContractInstanceState? snapshot = null) => + new(false, reasonCode, snapshot, null); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs new file mode 100644 index 0000000..67be3ba --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// Stable deny reason codes for (NEO-147). +public static class ContractGeneratorReasonCodes +{ + public const string PlayerNotWritable = ContractInstanceReasonCodes.PlayerNotWritable; + + public const string ActiveContractExists = ContractInstanceReasonCodes.ActiveContractExists; + + public const string InvalidIds = ContractInstanceReasonCodes.InvalidIds; + + public const string NoEligibleTemplate = "no_eligible_template"; + + public const string UnknownTemplate = "unknown_template"; +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs index 3317557..963bb19 100644 --- a/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs +++ b/server/NeonSprawl.Server/Game/Contracts/ContractInstanceIds.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; + namespace NeonSprawl.Server.Game.Contracts; /// Id normalization for contract instance stores (NEO-146). @@ -40,4 +43,23 @@ public static class ContractInstanceIds return $"{p}\0{i}"; } + + /// + /// Deterministic contract instance id from normalized seed inputs (NEO-147). + /// Format: ci_{32 lowercase hex} from SHA-256 of {playerId}|{templateId}|{seedBucket}. + /// + public static string MakeDeterministicInstanceId(string? playerId, string? templateId, string? seedBucket) + { + var p = NormalizePlayerId(playerId); + var t = NormalizeTemplateId(templateId); + var b = NormalizeSeedBucket(seedBucket); + if (p.Length == 0 || t.Length == 0 || b.Length == 0) + { + return string.Empty; + } + + var payload = $"{p}|{t}|{b}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return $"ci_{Convert.ToHexStringLower(hash.AsSpan(0, 16))}"; + } } diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs b/server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs new file mode 100644 index 0000000..afea0dd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractIssueOperationResult.cs @@ -0,0 +1,11 @@ +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Server-internal contract issue resolution envelope (NEO-147). +/// HTTP DTOs via E7M4-08 (NEO-151). +/// +public readonly record struct ContractIssueOperationResult( + bool Success, + string? ReasonCode, + ContractInstanceState? Snapshot, + string? EncounterTemplateId); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index 5d36b66..b0566b2 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -260,33 +260,25 @@ public static class QuestDefinitionCatalogLoader objectiveIdsSeen = new HashSet(StringComparer.Ordinal); var parsedSteps = new List(); - var stepsNode = rowObj["steps"] as JsonArray; - if (stepsNode is null) + var stepsNode = rowObj["steps"]; + if (stepsNode is not JsonArray stepsArray) { steps = parsedSteps; return errors; } - for (var si = 0; si < stepsNode.Count; si++) + for (var si = 0; si < stepsArray.Count; si++) { - if (stepsNode[si] is not JsonObject stepObj) + if (stepsArray[si] is not JsonObject stepObj) continue; var sid = (stepObj["id"] as JsonValue)?.GetValue(); - if (sid is not null) + if (sid is not null && !stepIdsSeen.Add(sid)) { - if (stepIdsSeen.Contains(sid)) - { - errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'"); - } - else - { - stepIdsSeen.Add(sid); - } + errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'"); } - var objectivesNode = stepObj["objectives"] as JsonArray; - if (objectivesNode is null) + if (stepObj["objectives"] is not JsonArray objectivesNode) continue; for (var oi = 0; oi < objectivesNode.Count; oi++) @@ -295,25 +287,18 @@ public static class QuestDefinitionCatalogLoader continue; var oid = (objObj["id"] as JsonValue)?.GetValue(); - if (oid is not null) + if (oid is not null && !objectiveIdsSeen.Add(oid)) { - if (objectiveIdsSeen.Contains(oid)) - { - errors.Add( - $"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " + - $"duplicate objective id '{oid}' within quest"); - } - else - { - objectiveIdsSeen.Add(oid); - } + errors.Add( + $"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " + + $"duplicate objective id '{oid}' within quest"); } } } - for (var si = 0; si < stepsNode.Count; si++) + for (var si = 0; si < stepsArray.Count; si++) { - if (stepsNode[si] is JsonObject stepObj) + if (stepsArray[si] is JsonObject stepObj) parsedSteps.Add(ParseStep(stepObj)); } @@ -331,7 +316,7 @@ public static class QuestDefinitionCatalogLoader return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules); } - private static IReadOnlyList ParseFactionGateRules(JsonArray? array) + private static List ParseFactionGateRules(JsonArray? array) { if (array is null || array.Count == 0) return []; @@ -361,7 +346,7 @@ public static class QuestDefinitionCatalogLoader return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants); } - private static IReadOnlyList ParseReputationGrantRows(JsonArray? array) + private static List ParseReputationGrantRows(JsonArray? array) { if (array is null || array.Count == 0) return []; @@ -380,7 +365,7 @@ public static class QuestDefinitionCatalogLoader return rows; } - private static IReadOnlyList ParseItemGrantRows(JsonArray? array) + private static List ParseItemGrantRows(JsonArray? array) { if (array is null || array.Count == 0) return []; @@ -399,7 +384,7 @@ public static class QuestDefinitionCatalogLoader return rows; } - private static IReadOnlyList ParseSkillXpGrantRows(JsonArray? array) + private static List ParseSkillXpGrantRows(JsonArray? array) { if (array is null || array.Count == 0) return []; @@ -457,7 +442,7 @@ public static class QuestDefinitionCatalogLoader return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId); } - private static IReadOnlyList ParseStringArray(JsonArray? array) + private static List ParseStringArray(JsonArray? array) { if (array is null) return []; diff --git a/server/README.md b/server/README.md index ce67670..5b39d9e 100644 --- a/server/README.md +++ b/server/README.md @@ -282,10 +282,38 @@ Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed b - **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**. - **`TryClearInstance`** — dev fixture only; removes one instance row. -Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation against **`IContractTemplateRegistry`** is deferred to NEO-147. +Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation and selection run in **`ContractGeneratorOperations`** (NEO-147). **Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresContractInstanceStore`** persists to **`contract_instance`** ([`V011__contract_instance.sql`](../db/migrations/V011__contract_instance.sql)) with a partial unique index enforcing one active row per player. Plan: [NEO-146 implementation plan](../../docs/plans/NEO-146-implementation-plan.md). +## Contract generator operations (NEO-147) + +**`ContractGeneratorOperations.TryIssue`** issues an **`active`** contract instance from seed inputs + player context. Call this orchestrator (or future HTTP wrapper) — not **`IContractInstanceStore.TryCreateActive`** directly — so template selection, faction gates, and deterministic ids stay centralized. + +**Inputs:** `playerId`, optional `templateId`, `seedBucket`, optional `zoneDifficultyBand` (defaults to **1**). + +**Template selection:** + +- **Explicit `templateId`:** registry lookup; deny **`unknown_template`** when missing; deny **`no_eligible_template`** when band or faction standing floor fails. +- **Omitted/empty `templateId`:** first template from **`GetDefinitionsInIdOrder()`** matching band + standing. +- Faction floor uses **`FactionGateOperations`** on the template **`minFactionStanding`** row. + +**Deterministic instance id:** **`ContractInstanceIds.MakeDeterministicInstanceId`** — SHA-256 of `{playerId}|{templateId}|{seedBucket}` (normalized) → **`ci_{32 lowercase hex}`**. + +**Reason codes:** + +| Code | When | +|------|------| +| **`invalid_ids`** | Empty player or seed bucket after normalize | +| **`player_not_writable`** | Player not in dev bucket / Postgres `player_position` | +| **`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 | + +**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). + ## Contract outcome store (NEO-146) Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**.