# 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.