From e0efaa3c15ea4d4ecb3af6ad6e6ee0b888f3c4b8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 27 Jun 2026 19:12:27 -0400 Subject: [PATCH] NEO-147: add E7M4-04 contract generator kickoff plan --- docs/plans/NEO-147-implementation-plan.md | 196 ++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/plans/NEO-147-implementation-plan.md diff --git a/docs/plans/NEO-147-implementation-plan.md b/docs/plans/NEO-147-implementation-plan.md new file mode 100644 index 0000000..f706feb --- /dev/null +++ b/docs/plans/NEO-147-implementation-plan.md @@ -0,0 +1,196 @@ +# 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 + +- [ ] Issue creates **`active`** instance with correct encounter binding. +- [ ] Second issue while active returns structured deny. +- [ ] `dotnet test` covers generator gates. + +## 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 | + +## 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.