13 KiB
NEO-147 — E7M4-04: ContractGeneratorOperations.TryIssue
Linear: NEO-147
Branch: NEO-147-e7m4-04-contract-generator-try-issue
Backlog: E7M4-pre-production-backlog.md — E7M4-04
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-117-implementation-plan.md (QuestStateOperations orchestrator + reason codes); NEO-146-implementation-plan.md (instance store primitives); NEO-145-implementation-plan.md (IContractTemplateRegistry)
Precursor: NEO-146 Done — IContractInstanceStore, IContractOutcomeStore (on main)
Blocks: NEO-149 (E7M4-06 completion wiring), NEO-150 (E7M4-07 economy lint at issue)
Client counterpart: 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 kickoff decisions table, 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} |
| 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.TryIssuestatic orchestrator inGame/Contracts/.ContractGeneratorReasonCodes+ContractIssueOperationResultenvelope.ContractInstanceIds.MakeDeterministicInstanceIdhelper.- Inputs:
playerId, optionaltemplateId,seedBucket, optionalzoneDifficultyBand(default 1), injected registries/stores +TimeProvider. - Template selection: band match + faction standing floor; structured deny paths.
TryCreateActivepersist with deterministic instance id +issuedAtfromTimeProvider.- 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 withprototype_combat_pocketencounter binding. - Optional Postgres integration test (
RequirePostgresFact): issue persists cross-factory (mirror NEO-146 persistence tests). server/README.mdcontract generator operations section.
Out of scope (from Linear + backlog):
POST …/contracts/issueHTTP + DTOs (NEO-151 / E7M4-08).ContractEconomyValidationat 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 — server HTTP must land in NEO-151 first.
Acceptance criteria checklist
- Issue creates
activeinstance with correct encounter binding. - Second issue while active returns structured deny.
dotnet testcovers 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.mdcontract generator section;E7_M4_ContractMissionGenerator.mdNEO-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):
public static ContractIssueOperationResult TryIssue(
string playerId,
string? templateId,
string seedBucket,
int? zoneDifficultyBand,
IContractTemplateRegistry templateRegistry,
IContractInstanceStore instanceStore,
IFactionStandingStore standingStore,
TimeProvider timeProvider)
Flow:
- Normalize
playerId,seedBucket; resolve band =zoneDifficultyBand ?? 1. - Deny
invalid_idswhen player or seed bucket empty after normalize. - Deny
player_not_writablewhen!instanceStore.CanWritePlayer(playerId). - Deny
active_contract_existswheninstanceStore.TryGetActiveForPlayerreturns true (include existing snapshot in result when helpful for tests — mirror questAlreadyActive). - Resolve template:
- Explicit
templateId:TryGetDefinition→unknown_templateif missing; else requireZoneDifficultyBand == bandandFactionGateOperations.TryEvaluatepasses on[MinFactionStanding]→ elseno_eligible_template. - Omitted/empty
templateId: filterGetDefinitionsInIdOrder()where band matches and faction gate passes; zero rows →no_eligible_template; pick first.
- Explicit
instanceId = MakeDeterministicInstanceId(playerId, template.Id, seedBucket); denyinvalid_idsif empty.instanceStore.TryCreateActive(playerId, instanceId, template.Id, seedBucket, timeProvider.GetUtcNow(), out snapshot)— expecttrue; on false (race), re-read active and denyactive_contract_exists.- Return success with snapshot +
template.EncounterTemplateId.
No outcome-store writes in E7M4-04.
3. Template selection helpers (private static in same file)
TrySelectTemplate(...)— keepsTryIssuereadable; unit-test selection indirectly through public API.- Prototype happy path: band 1, open standing 0, single template
prototype_contract_clear_combat_pocket→ encounterprototype_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.mdruntime paragraph when implementation lands.
6. Implementation order
ContractGeneratorReasonCodes,ContractIssueOperationResult,MakeDeterministicInstanceId.ContractGeneratorOperations.TryIssue+ selection helpers.- Unit tests → integration test → optional Postgres test.
server/README.md.- 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.