Merge pull request #188 from ViPro-Technologies/NEO-147-e7m4-04-contract-generator-try-issue

NEO-147: E7M4-04 contract generator TryIssue
pull/189/head
VinPropane 2026-06-27 19:50:36 -04:00 committed by GitHub
commit 52ab7dbec2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1040 additions and 44 deletions

View File

@ -57,6 +57,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**Runtime stores (NEO-146):** per-player **`IContractInstanceStore`** (one active instance) + append-only **`IContractOutcomeStore`**; [server README — Contract instance store](../../../server/README.md#contract-instance-store-neo-146). **Runtime stores (NEO-146):** per-player **`IContractInstanceStore`** (one active instance) + append-only **`IContractOutcomeStore`**; [server README — Contract instance store](../../../server/README.md#contract-instance-store-neo-146).
**Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147).
## Source anchors ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

File diff suppressed because one or more lines are too long

View File

@ -118,7 +118,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete. **E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete.
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | In Progress | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | In Progress |
**E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); **E7M4-03 / NEO-146** contract instance + outcome stores landed ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). **E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); **E7M4-03 / NEO-146** contract instance + outcome stores landed ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); **E7M4-04 / NEO-147** contract generator **`TryIssue`** landed ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154).
### Epic 8 — Social / Guild ### Epic 8 — Social / Guild

View File

@ -0,0 +1,204 @@
# 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.

View File

@ -0,0 +1,63 @@
# Code review — NEO-147 (E7M4-04 contract generator TryIssue)
**Date:** 2026-06-27
**Scope:** Branch `NEO-147-e7m4-04-contract-generator-try-issue` — commits `7f62f43`…`c37c3c7` (5 commits)
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
NEO-147 adds `ContractGeneratorOperations.TryIssue`, a server-authoritative orchestrator that selects a contract template (explicit id or auto-select by band + faction standing), mints a deterministic instance id (`ci_{32 hex}`), and persists via `IContractInstanceStore.TryCreateActive`. Reason codes align with NEO-146 store codes where shared; generator-specific codes cover unknown template and no eligible template. The flow mirrors `QuestStateOperations` / NEO-117 patterns: early player/active gates, delegated faction evaluation, structured deny envelopes with optional snapshots, and race-safe create-failure handling. Eleven AAA unit tests, one DI integration test, and one optional Postgres persistence test cover happy paths and primary deny gates; `dotnet test` passes **888** tests. Risk is low — orchestrator-only, no HTTP or client surface; downstream NEO-149/NEO-151 consume this layer.
## Documentation checked
| Document | Alignment |
|----------|-----------|
| `docs/plans/NEO-147-implementation-plan.md` | **Matches** — AC checklist complete; reconciliation reflects shipped types, orchestrator, tests (11 unit + DI + Postgres), README, module note. Minor drift: plan “Files to add” omits `ContractGeneratorPersistenceIntegrationTests.cs` (listed under Tests section only). |
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-04) | **Matches** — template selection, active cap, deterministic ids, structured denies; economy lint, HTTP, completion wiring correctly out of scope. |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-147 generator operations paragraph + README link added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches** — E7.M4 row stops at E7M4-03/NEO-146; should add E7M4-04/NEO-147 generator ops after merge (same follow-up pattern as NEO-146).~~ **Done.** — E7M4-04 / NEO-147 generator ops added to E7.M4 row. |
| `docs/decomposition/modules/module_dependency_register.md` | ~~**Partially matches** — E7.M4 note ends at NEO-146; optional one-line NEO-147 after merge.~~ **Done.** — E7M4-04 / NEO-147 note added. |
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — server orchestrator; authority unchanged. |
| Full-stack epic decomposition | **N/A** — server-only; client counterpart NEO-153 correctly deferred until NEO-151 HTTP. |
~~Register/tracking tables should be updated for E7M4-04 after merge — **should fix** (non-blocking), mirroring NEO-146 alignment follow-up.~~ **Done.**
## Blocking issues
None.
## Suggestions
1. ~~**Update decomposition tracking for E7M4-04** — After merge, extend `documentation_and_implementation_alignment.md` E7.M4 row (and optionally `module_dependency_register.md`) with E7M4-04: `ContractGeneratorOperations.TryIssue`, reason codes, deterministic ids, README section — same pattern as NEO-145/NEO-146.~~ **Done.**
2. ~~**Faction standing deny path** — Plan and README document faction floor via `FactionGateOperations`; unit tests cover band mismatch but not standing failure → `no_eligible_template`. Prototype template min standing is **0** (always passes with default standing), so add a unit test with a synthetic template row (`minStanding: 1`) and zero player standing to lock the orchestrator wiring (distinct from `FactionGateOperationsTests`).~~ **Done.**`TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin`.
3. ~~**Duplicate instance id after complete** — Plan adopted: re-issue with the same `seedBucket` after completion hits store duplicate-id deny; orchestrator maps to `invalid_ids` via `DenyCreateFailure`. Add a unit test (complete first issue, re-issue same seed) to document caller contract (“rotate `seedBucket` for a new contract”).~~ **Done.**`TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete`.
## Nits
- ~~Nit: `MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket` has an empty `// Act` block — move equality assertions into Act or fold into Arrange per AAA convention.~~ **Done.**
- ~~Nit: `ContractGeneratorPersistenceIntegrationTests` mixes second-factory read into Act; acceptable but slightly blurs Act vs Assert compared to other integration tests.~~ **Done.** — cross-factory read moved to Assert.
- Nit: `DenyCreateFailure` fallback `invalid_ids` for duplicate completed instance id is correct per plan but coarse for future HTTP mappers — consider a dedicated reason code if NEO-151 needs clearer client messaging (defer unless HTTP design requires it).
## Verification
```bash
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln
cd /Users/don/neon-sprawl && dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractGenerator"
```
Optional (Postgres configured):
```bash
export ConnectionStrings__NeonSprawl='…'
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractGeneratorPersistence"
```
Manual Godot QA: **none** (server orchestrator per plan).

View File

@ -0,0 +1,53 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractGeneratorOperationsIntegrationTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string SeedBucket = "2026-06-22";
[Fact]
public async Task TryIssue_ShouldCreateActiveInstanceWithEncounterBinding_WhenResolvedFromDi()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
using var scope = factory.Services.CreateScope();
var templateRegistry = scope.ServiceProvider.GetRequiredService<IContractTemplateRegistry>();
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
var standingStore = scope.ServiceProvider.GetRequiredService<IFactionStandingStore>();
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
var result = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
templateRegistry,
instanceStore,
standingStore,
timeProvider);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
Assert.Equal(EncounterId, result.EncounterTemplateId);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
Assert.True(instanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
}
}

View File

@ -0,0 +1,351 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractGeneratorOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string GridOperatorsFactionId = "prototype_faction_grid_operators";
private const string GatedTemplateId = "test_gated_contract_template";
private const string SeedBucket = "2026-06-22";
private const string AlternateSeedBucket = "2026-06-23";
private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void TryIssue_ShouldCreateActiveInstance_WhenTemplateEligible()
{
// Arrange
var deps = CreateDependencies();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
Assert.Equal(PlayerId, result.Snapshot.PlayerId);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
Assert.Equal(SeedBucket, result.Snapshot.SeedBucket);
Assert.Equal(IssuedAt, result.Snapshot.IssuedAt);
Assert.Equal(EncounterId, result.EncounterTemplateId);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
}
[Fact]
public void TryIssue_ShouldAutoSelectTemplate_WhenTemplateIdOmitted()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket);
// Assert
Assert.True(result.Success);
Assert.Equal(TemplateId, result.Snapshot!.TemplateId);
Assert.Equal(EncounterId, result.EncounterTemplateId);
}
[Fact]
public void MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket()
{
// Arrange
// Act
var first = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
var same = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
var different = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, AlternateSeedBucket);
// Assert
Assert.Equal(first, same);
Assert.NotEqual(first, different);
Assert.StartsWith("ci_", first, StringComparison.Ordinal);
}
[Fact]
public void TryIssue_ShouldDenyActiveContractExists_WhenPlayerAlreadyHasActiveInstance()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
// Assert
Assert.False(second.Success);
Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, second.ReasonCode);
Assert.NotNull(second.Snapshot);
Assert.Equal(first.Snapshot!.ContractInstanceId, second.Snapshot!.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
Assert.Null(second.EncounterTemplateId);
}
[Fact]
public void TryIssue_ShouldAllowNewIssue_WhenPriorInstanceCompleted()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
Assert.True(deps.InstanceStore.TryMarkComplete(
PlayerId,
first.Snapshot!.ContractInstanceId,
CompletedAt,
out _));
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
// Assert
Assert.True(second.Success);
Assert.NotEqual(first.Snapshot.ContractInstanceId, second.Snapshot!.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
Assert.Equal(EncounterId, second.EncounterTemplateId);
}
[Fact]
public void TryIssue_ShouldDenyUnknownTemplate_WhenExplicitTemplateMissing()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: "not_a_real_template", seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.UnknownTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenBandMismatch()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket, zoneDifficultyBand: 99);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenAutoSelectBandHasNoTemplates()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket, zoneDifficultyBand: 99);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var gatedTemplate = prototype with
{
Id = GatedTemplateId,
MinFactionStanding = new FactionGateRuleRow(GridOperatorsFactionId, 1),
};
var deps = CreateDependencies(gatedTemplate);
// Act
var result = TryIssue(deps, templateId: GatedTemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
Assert.True(deps.InstanceStore.TryMarkComplete(
PlayerId,
first.Snapshot!.ContractInstanceId,
CompletedAt,
out _));
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(second.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, second.ReasonCode);
Assert.NotNull(second.Snapshot);
Assert.Equal(ContractInstanceStatus.Completed, second.Snapshot!.Status);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenCreateActiveReturnsFalseWithoutSnapshot()
{
// Arrange
var deps = CreateDependencies();
var store = new CreateActiveNullSnapshotStore(deps.InstanceStore);
var templateRegistry = deps.TemplateRegistry;
var standingStore = deps.StandingStore;
// Act
var result = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
templateRegistry,
store,
standingStore,
deps.TimeProvider);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyPlayerNotWritable_WhenPlayerUnknown()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, playerId: UnknownPlayerId, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.PlayerNotWritable, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenPlayerIdEmpty()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, playerId: " ", templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenSeedBucketEmpty()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: " ");
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
}
private static ContractIssueOperationResult TryIssue(
GeneratorTestDependencies deps,
string? playerId = PlayerId,
string? templateId = TemplateId,
string seedBucket = SeedBucket,
int? zoneDifficultyBand = null) =>
ContractGeneratorOperations.TryIssue(
playerId!,
templateId,
seedBucket,
zoneDifficultyBand,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
private static GeneratorTestDependencies CreateDependencies(params ContractTemplateRow[] templates)
{
var rows = templates.Length == 0
? new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[TemplateId] = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId],
}
: templates.ToDictionary(static t => t.Id, StringComparer.Ordinal);
var catalog = new ContractTemplateCatalog("/tmp/catalog", rows, catalogJsonFileCount: 1);
var templateRegistry = new ContractTemplateRegistry(catalog);
var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
var instanceStore = new InMemoryContractInstanceStore(positionOptions);
var standingStore = CreateStandingStore();
return new GeneratorTestDependencies(
templateRegistry,
instanceStore,
standingStore,
new FakeTimeProvider(IssuedAt));
}
private static InMemoryFactionStandingStore CreateStandingStore()
{
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
var registry = new FactionDefinitionRegistry(catalog);
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryFactionStandingStore(options, registry);
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private readonly record struct GeneratorTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider);
/// <summary>Simulates store create failure without populating the out snapshot (Bugbot regression).</summary>
private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore
{
public bool CanWritePlayer(string playerId) => inner.CanWritePlayer(playerId);
public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) =>
inner.TryGet(playerId, contractInstanceId, out snapshot);
public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) =>
inner.TryGetActiveForPlayer(playerId, out snapshot);
public bool TryCreateActive(
string playerId,
string contractInstanceId,
string templateId,
string seedBucket,
DateTimeOffset issuedAt,
out ContractInstanceState snapshot)
{
snapshot = null!;
return false;
}
public bool TryMarkComplete(
string playerId,
string contractInstanceId,
DateTimeOffset completedAt,
out ContractInstanceState snapshot) =>
inner.TryMarkComplete(playerId, contractInstanceId, completedAt, out snapshot);
public bool TryClearInstance(string playerId, string contractInstanceId) =>
inner.TryClearInstance(playerId, contractInstanceId);
}
}

View File

@ -0,0 +1,96 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
[Collection("Postgres integration")]
public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string SeedBucket = "2026-06-22";
[RequirePostgresFact]
public async Task TryIssue_ShouldPersistActiveInstanceAcrossNewFactory()
{
// Arrange
await ResetContractInstanceTableAsync();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
ContractIssueOperationResult issued;
using (var firstScope = Factory.Services.CreateScope())
{
var services = firstScope.ServiceProvider;
issued = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<TimeProvider>());
}
// Assert
Assert.True(issued.Success);
Assert.Equal(expectedInstanceId, issued.Snapshot!.ContractInstanceId);
Assert.Equal(EncounterId, issued.EncounterTemplateId);
await using var secondFactory = new PostgresWebApplicationFactory();
using var secondScope = secondFactory.Services.CreateScope();
var readStore = secondScope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
Assert.True(readStore.TryGetActiveForPlayer(PlayerId, out var readBack));
Assert.Equal(expectedInstanceId, readBack.ContractInstanceId);
Assert.Equal(TemplateId, readBack.TemplateId);
Assert.Equal(ContractInstanceStatus.Active, readBack.Status);
Assert.Equal(SeedBucket, readBack.SeedBucket);
}
private async Task ResetContractInstanceTableAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var instanceDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V011__contract_instance.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(instanceDdlPath))
{
throw new FileNotFoundException("Test DDL for contract generator persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var instanceDdl = await File.ReadAllTextAsync(instanceDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using var applyPosition = new NpgsqlCommand(positionDdl, conn);
await applyPosition.ExecuteNonQueryAsync();
await using var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn);
await truncate.ExecuteNonQueryAsync();
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using var applyInstance = new NpgsqlCommand(instanceDdl, conn);
await applyInstance.ExecuteNonQueryAsync();
}
}

View File

@ -3,7 +3,6 @@ using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.Factions; namespace NeonSprawl.Server.Tests.Game.Factions;
@ -12,6 +11,7 @@ public sealed class FactionGateOperationsTests
{ {
private const string PlayerId = "dev-local-1"; private const string PlayerId = "dev-local-1";
private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding; private const int GridMinStanding = PrototypeE7M3QuestFactionRules.GridContractMinStanding;
[Fact] [Fact]
@ -63,19 +63,18 @@ public sealed class FactionGateOperationsTests
{ {
// Arrange // Arrange
var standingStore = CreateStandingStore(); var standingStore = CreateStandingStore();
const string rustFactionId = "prototype_faction_rust_collective";
Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success); Assert.True(standingStore.TryApplyStandingDelta(PlayerId, GridFactionId, GridMinStanding).Success);
var rules = new[] var rules = new[]
{ {
new FactionGateRuleRow(GridFactionId, GridMinStanding), new FactionGateRuleRow(GridFactionId, GridMinStanding),
new FactionGateRuleRow(rustFactionId, 1), new FactionGateRuleRow(RustCollectiveFactionId, 1),
}; };
// Act // Act
var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore); var outcome = FactionGateOperations.TryEvaluate(PlayerId, rules, standingStore);
// Assert // Assert
Assert.False(outcome.Success); Assert.False(outcome.Success);
Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode); Assert.Equal(FactionGateEvaluateReasonCodes.GateBlocked, outcome.ReasonCode);
Assert.Equal(rustFactionId, outcome.FailingFactionId); Assert.Equal(RustCollectiveFactionId, outcome.FailingFactionId);
Assert.Equal(1, outcome.RequiredMinStanding); Assert.Equal(1, outcome.RequiredMinStanding);
Assert.Equal(0, outcome.CurrentStanding); Assert.Equal(0, outcome.CurrentStanding);
} }
@ -120,12 +119,10 @@ public sealed class FactionGateOperationsTests
private static FactionDefinitionRegistry CreatePrototypeRegistry() private static FactionDefinitionRegistry CreatePrototypeRegistry()
{ {
var gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
var rustFactionId = "prototype_faction_rust_collective";
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal) var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{ {
[gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId], [GridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridFactionId],
[rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId], [RustCollectiveFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[RustCollectiveFactionId],
}; };
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1); var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new FactionDefinitionRegistry(catalog); return new FactionDefinitionRegistry(catalog);

View File

@ -0,0 +1,169 @@
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147).
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152).
/// </summary>
public static class ContractGeneratorOperations
{
/// <summary>
/// Issues an active contract instance when template selection and store gates pass.
/// </summary>
public static ContractIssueOperationResult TryIssue(
string playerId,
string? templateId,
string seedBucket,
int? zoneDifficultyBand,
IContractTemplateRegistry templateRegistry,
IContractInstanceStore instanceStore,
IFactionStandingStore standingStore,
TimeProvider timeProvider)
{
var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId);
var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(seedBucket);
var band = zoneDifficultyBand ?? 1;
if (normalizedPlayerId.Length == 0 || normalizedSeedBucket.Length == 0)
{
return Deny(ContractGeneratorReasonCodes.InvalidIds);
}
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
{
return Deny(ContractGeneratorReasonCodes.PlayerNotWritable);
}
if (instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot))
{
return Deny(ContractGeneratorReasonCodes.ActiveContractExists, activeSnapshot);
}
if (!TrySelectTemplate(
templateId,
band,
normalizedPlayerId,
templateRegistry,
standingStore,
out var template,
out var denyReason))
{
return Deny(denyReason!);
}
var instanceId = ContractInstanceIds.MakeDeterministicInstanceId(
normalizedPlayerId,
template!.Id,
normalizedSeedBucket);
if (instanceId.Length == 0)
{
return Deny(ContractGeneratorReasonCodes.InvalidIds);
}
if (instanceStore.TryCreateActive(
normalizedPlayerId,
instanceId,
template.Id,
normalizedSeedBucket,
timeProvider.GetUtcNow(),
out var snapshot))
{
return Success(snapshot, template.EncounterTemplateId);
}
return DenyCreateFailure(instanceStore, normalizedPlayerId, snapshot);
}
private static bool TrySelectTemplate(
string? templateId,
int band,
string normalizedPlayerId,
IContractTemplateRegistry templateRegistry,
IFactionStandingStore standingStore,
out ContractTemplateRow? template,
out string? denyReason)
{
template = null;
denyReason = null;
var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(templateId);
if (normalizedTemplateId.Length > 0)
{
if (!templateRegistry.TryGetDefinition(normalizedTemplateId, out template))
{
denyReason = ContractGeneratorReasonCodes.UnknownTemplate;
return false;
}
if (!IsEligible(template, band, normalizedPlayerId, standingStore))
{
denyReason = ContractGeneratorReasonCodes.NoEligibleTemplate;
template = null;
return false;
}
return true;
}
foreach (var candidate in templateRegistry.GetDefinitionsInIdOrder())
{
if (!IsEligible(candidate, band, normalizedPlayerId, standingStore))
{
continue;
}
template = candidate;
return true;
}
denyReason = ContractGeneratorReasonCodes.NoEligibleTemplate;
return false;
}
private static bool IsEligible(
ContractTemplateRow template,
int band,
string normalizedPlayerId,
IFactionStandingStore standingStore)
{
if (template.ZoneDifficultyBand != band)
{
return false;
}
var gate = FactionGateOperations.TryEvaluate(
normalizedPlayerId,
[template.MinFactionStanding],
standingStore);
return gate.Success;
}
private static ContractIssueOperationResult DenyCreateFailure(
IContractInstanceStore instanceStore,
string normalizedPlayerId,
ContractInstanceState? attemptSnapshot)
{
if (instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var active))
{
return Deny(ContractGeneratorReasonCodes.ActiveContractExists, active);
}
if (attemptSnapshot is { Status: ContractInstanceStatus.Active })
{
return Deny(ContractGeneratorReasonCodes.ActiveContractExists, attemptSnapshot);
}
return Deny(ContractGeneratorReasonCodes.InvalidIds, attemptSnapshot);
}
private static ContractIssueOperationResult Success(
ContractInstanceState snapshot,
string encounterTemplateId) =>
new(true, null, snapshot, encounterTemplateId);
private static ContractIssueOperationResult Deny(
string reasonCode,
ContractInstanceState? snapshot = null) =>
new(false, reasonCode, snapshot, null);
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Stable deny reason codes for <see cref="ContractGeneratorOperations.TryIssue"/> (NEO-147).</summary>
public static class ContractGeneratorReasonCodes
{
public const string PlayerNotWritable = ContractInstanceReasonCodes.PlayerNotWritable;
public const string ActiveContractExists = ContractInstanceReasonCodes.ActiveContractExists;
public const string InvalidIds = ContractInstanceReasonCodes.InvalidIds;
public const string NoEligibleTemplate = "no_eligible_template";
public const string UnknownTemplate = "unknown_template";
}

View File

@ -1,3 +1,6 @@
using System.Security.Cryptography;
using System.Text;
namespace NeonSprawl.Server.Game.Contracts; namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Id normalization for contract instance stores (NEO-146).</summary> /// <summary>Id normalization for contract instance stores (NEO-146).</summary>
@ -40,4 +43,23 @@ public static class ContractInstanceIds
return $"{p}\0{i}"; return $"{p}\0{i}";
} }
/// <summary>
/// Deterministic contract instance id from normalized seed inputs (NEO-147).
/// Format: <c>ci_{32 lowercase hex}</c> from SHA-256 of <c>{playerId}|{templateId}|{seedBucket}</c>.
/// </summary>
public static string MakeDeterministicInstanceId(string? playerId, string? templateId, string? seedBucket)
{
var p = NormalizePlayerId(playerId);
var t = NormalizeTemplateId(templateId);
var b = NormalizeSeedBucket(seedBucket);
if (p.Length == 0 || t.Length == 0 || b.Length == 0)
{
return string.Empty;
}
var payload = $"{p}|{t}|{b}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload));
return $"ci_{Convert.ToHexStringLower(hash.AsSpan(0, 16))}";
}
} }

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Server-internal contract issue resolution envelope (NEO-147).
/// HTTP DTOs via E7M4-08 (NEO-151).
/// </summary>
public readonly record struct ContractIssueOperationResult(
bool Success,
string? ReasonCode,
ContractInstanceState? Snapshot,
string? EncounterTemplateId);

View File

@ -260,33 +260,25 @@ public static class QuestDefinitionCatalogLoader
objectiveIdsSeen = new HashSet<string>(StringComparer.Ordinal); objectiveIdsSeen = new HashSet<string>(StringComparer.Ordinal);
var parsedSteps = new List<QuestStepDefRow>(); var parsedSteps = new List<QuestStepDefRow>();
var stepsNode = rowObj["steps"] as JsonArray; var stepsNode = rowObj["steps"];
if (stepsNode is null) if (stepsNode is not JsonArray stepsArray)
{ {
steps = parsedSteps; steps = parsedSteps;
return errors; return errors;
} }
for (var si = 0; si < stepsNode.Count; si++) for (var si = 0; si < stepsArray.Count; si++)
{ {
if (stepsNode[si] is not JsonObject stepObj) if (stepsArray[si] is not JsonObject stepObj)
continue; continue;
var sid = (stepObj["id"] as JsonValue)?.GetValue<string>(); var sid = (stepObj["id"] as JsonValue)?.GetValue<string>();
if (sid is not null) if (sid is not null && !stepIdsSeen.Add(sid))
{
if (stepIdsSeen.Contains(sid))
{ {
errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'"); errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'");
} }
else
{
stepIdsSeen.Add(sid);
}
}
var objectivesNode = stepObj["objectives"] as JsonArray; if (stepObj["objectives"] is not JsonArray objectivesNode)
if (objectivesNode is null)
continue; continue;
for (var oi = 0; oi < objectivesNode.Count; oi++) for (var oi = 0; oi < objectivesNode.Count; oi++)
@ -295,25 +287,18 @@ public static class QuestDefinitionCatalogLoader
continue; continue;
var oid = (objObj["id"] as JsonValue)?.GetValue<string>(); var oid = (objObj["id"] as JsonValue)?.GetValue<string>();
if (oid is not null) if (oid is not null && !objectiveIdsSeen.Add(oid))
{
if (objectiveIdsSeen.Contains(oid))
{ {
errors.Add( errors.Add(
$"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " + $"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " +
$"duplicate objective id '{oid}' within quest"); $"duplicate objective id '{oid}' within quest");
} }
else
{
objectiveIdsSeen.Add(oid);
}
}
} }
} }
for (var si = 0; si < stepsNode.Count; si++) for (var si = 0; si < stepsArray.Count; si++)
{ {
if (stepsNode[si] is JsonObject stepObj) if (stepsArray[si] is JsonObject stepObj)
parsedSteps.Add(ParseStep(stepObj)); parsedSteps.Add(ParseStep(stepObj));
} }
@ -331,7 +316,7 @@ public static class QuestDefinitionCatalogLoader
return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules); return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, completionRewardBundle, factionGateRules);
} }
private static IReadOnlyList<FactionGateRuleRow> ParseFactionGateRules(JsonArray? array) private static List<FactionGateRuleRow> ParseFactionGateRules(JsonArray? array)
{ {
if (array is null || array.Count == 0) if (array is null || array.Count == 0)
return []; return [];
@ -361,7 +346,7 @@ public static class QuestDefinitionCatalogLoader
return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants); return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants);
} }
private static IReadOnlyList<ReputationGrantRow> ParseReputationGrantRows(JsonArray? array) private static List<ReputationGrantRow> ParseReputationGrantRows(JsonArray? array)
{ {
if (array is null || array.Count == 0) if (array is null || array.Count == 0)
return []; return [];
@ -380,7 +365,7 @@ public static class QuestDefinitionCatalogLoader
return rows; return rows;
} }
private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array) private static List<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
{ {
if (array is null || array.Count == 0) if (array is null || array.Count == 0)
return []; return [];
@ -399,7 +384,7 @@ public static class QuestDefinitionCatalogLoader
return rows; return rows;
} }
private static IReadOnlyList<QuestSkillXpGrantRow> ParseSkillXpGrantRows(JsonArray? array) private static List<QuestSkillXpGrantRow> ParseSkillXpGrantRows(JsonArray? array)
{ {
if (array is null || array.Count == 0) if (array is null || array.Count == 0)
return []; return [];
@ -457,7 +442,7 @@ public static class QuestDefinitionCatalogLoader
return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId); return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId);
} }
private static IReadOnlyList<string> ParseStringArray(JsonArray? array) private static List<string> ParseStringArray(JsonArray? array)
{ {
if (array is null) if (array is null)
return []; return [];

View File

@ -282,10 +282,38 @@ Per-player contract runtime state lives in **`IContractInstanceStore`**, keyed b
- **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**. - **`TryMarkComplete`** — first completion returns `true`; replay returns `false` without changing **`completedAt`**.
- **`TryClearInstance`** — dev fixture only; removes one instance row. - **`TryClearInstance`** — dev fixture only; removes one instance row.
Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation against **`IContractTemplateRegistry`** is deferred to NEO-147. Completed instances are immutable (no status regression). Player, template, and instance ids are normalized (trim + lowercase for player/template/instance; seed bucket trim only). Template id validation and selection run in **`ContractGeneratorOperations`** (NEO-147).
**Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresContractInstanceStore`** persists to **`contract_instance`** ([`V011__contract_instance.sql`](../db/migrations/V011__contract_instance.sql)) with a partial unique index enforcing one active row per player. Plan: [NEO-146 implementation plan](../../docs/plans/NEO-146-implementation-plan.md). **Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresContractInstanceStore`** persists to **`contract_instance`** ([`V011__contract_instance.sql`](../db/migrations/V011__contract_instance.sql)) with a partial unique index enforcing one active row per player. Plan: [NEO-146 implementation plan](../../docs/plans/NEO-146-implementation-plan.md).
## Contract generator operations (NEO-147)
**`ContractGeneratorOperations.TryIssue`** issues an **`active`** contract instance from seed inputs + player context. Call this orchestrator (or future HTTP wrapper) — not **`IContractInstanceStore.TryCreateActive`** directly — so template selection, faction gates, and deterministic ids stay centralized.
**Inputs:** `playerId`, optional `templateId`, `seedBucket`, optional `zoneDifficultyBand` (defaults to **1**).
**Template selection:**
- **Explicit `templateId`:** registry lookup; deny **`unknown_template`** when missing; deny **`no_eligible_template`** when band or faction standing floor fails.
- **Omitted/empty `templateId`:** first template from **`GetDefinitionsInIdOrder()`** matching band + standing.
- Faction floor uses **`FactionGateOperations`** on the template **`minFactionStanding`** row.
**Deterministic instance id:** **`ContractInstanceIds.MakeDeterministicInstanceId`** — SHA-256 of `{playerId}|{templateId}|{seedBucket}` (normalized) → **`ci_{32 lowercase hex}`**.
**Reason codes:**
| Code | When |
|------|------|
| **`invalid_ids`** | Empty player or seed bucket after normalize |
| **`player_not_writable`** | Player not in dev bucket / Postgres `player_position` |
| **`active_contract_exists`** | Player already has an active instance |
| **`unknown_template`** | Explicit template id not in registry |
| **`no_eligible_template`** | No template matches band + standing filters |
**Result:** **`ContractIssueOperationResult`** — `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, **`encounterTemplateId`** from the selected template.
Economy band-cap lint at issue time lands in **NEO-150** (E7M4-07). HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md).
## Contract outcome store (NEO-146) ## Contract outcome store (NEO-146)
Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**. Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**.