neon-sprawl/docs/plans/NEO-147-implementation-plan.md

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.mdE7M4-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 DoneIContractInstanceStore, 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 ContractIssueOperationResultsuccess, 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 — 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.

Implementation reconciliation (shipped)

  • Types: ContractGeneratorReasonCodes, ContractIssueOperationResult, ContractInstanceIds.MakeDeterministicInstanceId.
  • Orchestrator: ContractGeneratorOperations.TryIssue — band + faction template selection, deterministic id, store persist.
  • Tests: ContractGeneratorOperationsTests (11 unit), ContractGeneratorOperationsIntegrationTests (DI), ContractGeneratorPersistenceIntegrationTests (RequirePostgresFact); 888 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):

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: TryGetDefinitionunknown_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.