Merge pull request #188 from ViPro-Technologies/NEO-147-e7m4-04-contract-generator-try-issue
NEO-147: E7M4-04 contract generator TryIssuepull/189/head
commit
52ab7dbec2
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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))}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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 [];
|
||||||
|
|
|
||||||
|
|
@ -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`**.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue