NEO-147: add E7M4-04 contract generator kickoff plan
parent
29bc2423b5
commit
e0efaa3c15
|
|
@ -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.
|
||||||
Loading…
Reference in New Issue