Compare commits

...

6 Commits

Author SHA1 Message Date
VinPropane e0d0be65c6
Merge pull request #192 from ViPro-Technologies/NEO-150-e7m4-07-economy-validation-lint-at-issue-time
NEO-150: E7M4-07 economy validation lint at issue time
2026-06-28 10:07:33 -04:00
VinPropane 70e3e9a17d NEO-150: Address code review findings
Extract ContractPrototypeTestFixtures, add skill XP over-cap test,
null-guard template, validation-order comment, and NEO-151 handoff note.
2026-06-28 10:01:08 -04:00
VinPropane e3d8458a66 NEO-150: Add code review for economy validation at issue 2026-06-28 09:56:37 -04:00
VinPropane d5de4e81ef NEO-150: Document economy validation at issue and reconcile plan
Update server README, E7.M4 module doc, alignment register, and
implementation plan reconciliation for E7M4-07.
2026-06-28 09:20:10 -04:00
VinPropane 3d49230c3c NEO-150: Add issue-time contract economy validation
Introduce ContractEconomyValidation.TryValidateTemplate and wire it
into TryIssue before persist with economy_cap_exceeded and
invalid_reward_bundle deny codes.
2026-06-28 09:20:09 -04:00
VinPropane 039542aa74 NEO-150: Add E7M4-07 economy validation kickoff plan
Document issue-time band-cap and cross-ref validation approach,
deny reason codes, file lists, and tests before implementation.
2026-06-28 09:10:59 -04:00
18 changed files with 627 additions and 6 deletions

View File

@ -59,6 +59,10 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**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). **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).
**Economy validation at issue (NEO-150):** **`ContractEconomyValidation.TryValidateTemplate`** — band caps + reward-bundle cross-refs before persist; deny **`economy_cap_exceeded`** / **`invalid_reward_bundle`**; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147).
**Reward parity stub:** prototype band caps enforce Slice 4 policy only. Full [E6.M4](E6_M4_RewardParityEnforcer.md) **`RewardParityMap`** and [E3.M5](E3_M5_EconomyBalancePolicy.md) balance dashboards are deferred.
**Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127). **Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127).
**Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149). **Contract completion wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — encounter clear completes matching active instance + outcome audit; [server README — Contract completion operations](../../../server/README.md#contract-completion-operations-neo-149).

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)); **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). **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)); **E7M4-07 / NEO-150** issue-time economy validation landed ([NEO-150 plan](../../plans/NEO-150-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,172 @@
# NEO-150 — E7M4-07: Economy validation lint at issue time
**Linear:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150)
**Branch:** `NEO-150-e7m4-07-economy-validation-lint-at-issue-time`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-07**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-145-implementation-plan.md](NEO-145-implementation-plan.md) (CI-parity gates + `PrototypeE7M4ContractCatalogRules`); [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) (`ContractGeneratorOperations.TryIssue` orchestrator)
**Precursor:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) **`Done`** — `ContractGeneratorOperations.TryIssue` (**on `main`**)
**Blocks:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) (E7M4-08 contract issue HTTP)
**Client counterpart:** none (server policy); readable deny copy lands in **NEO-153** (E7M4-10)
## Goal
Slice 4 AC — issued contracts validate against **prototype economy caps** and **reward bundle cross-refs** at issue time (defense in depth after CI + startup catalog load).
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|-------|----------|---------------------|--------|
| Deny reason codes | Single vs split codes for cap vs cross-ref failures? | **Split:** `economy_cap_exceeded` for band-cap violations; `invalid_reward_bundle` for unknown item/skill/faction refs or disallowed skill XP source — keeps E7M4-10 HUD copy precise and matches backlog unit-test split (over-cap vs unknown id). | **Adopted** — user chose separate codes |
| Validation reuse | New logic vs delegate to `PrototypeE7M4ContractCatalogRules`? | **Delegate**`ContractEconomyValidation.TryValidateTemplate` calls existing `TryGetBandCapGateError` + `TryGetCrossRefError` on a single-row map; same constants as CI/loader (`BandCapsByDifficultyBand`). | **Adopted** — repo precedent (NEO-145) |
| Cross-ref scope at issue | Bundle-only vs full loader parity? | **Full loader parity** for the selected template (encounter, faction gate, bundle item/skill/faction refs, `mission_reward` allowlist) via live registries — matches backlog “registry lookups” and “slipped through” AC. | **Adopted** — backlog in-scope list |
| TryIssue signature | Add registry parameters vs hide inside validation service? | **Add registry parameters** to `TryIssue` (encounter, item, skill, faction registries + skill defs for allowlist) — mirror `ContractCompletionOperations` / loader cross-ref deps; static orchestrator pattern unchanged. | **Adopted** — NEO-147 orchestrator precedent |
| Startup vs issue-time | Re-validate when catalog already passed load? | **Yes** — issue-time lint is fail-closed defense; production path always passes; unit tests inject tampered in-memory templates to prove deny. | **Adopted** — Linear AC “cannot issue if slipped through” |
| HTTP / Godot / telemetry | In scope? | **Out of scope** — NEO-151 HTTP, NEO-153 client HUD, NEO-152 comment hooks. | **Adopted** — backlog |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`ContractEconomyValidation.TryValidateTemplate`** — band caps + cross-ref for one `ContractTemplateRow` using `PrototypeE7M4ContractCatalogRules` helpers and injected registries.
- Call from **`ContractGeneratorOperations.TryIssue`** after template selection, **before** `TryCreateActive`; fail-closed deny.
- **`ContractGeneratorReasonCodes`:** `economy_cap_exceeded`, `invalid_reward_bundle`.
- Unit tests: at-cap pass (prototype freeze), over-cap deny, unknown bundle id deny.
- Confirm startup/CI path unchanged: tampered catalog still fails `ContractTemplateCatalogLoader` / `validate_content.py` (existing NEO-144/NEO-145 tests).
- `server/README.md` contract generator section — economy validation reason codes + issue-time gate.
- **E7_M4** module doc — economy validation note + parity stub toward E6.M4 / E3.M5 (full map deferred).
**Out of scope (from Linear + backlog):**
- Full **`RewardParityMap`** (E6.M4); live ops dashboards (E9.M2 / E3.M5).
- **`POST …/contracts/issue`** HTTP (NEO-151).
- Godot client deny copy (NEO-153).
- Telemetry **`reward_anomaly`** comment hooks (NEO-152).
## Acceptance criteria checklist
- [x] Template at freeze caps issues successfully.
- [x] Tampered catalog row over caps fails at startup (CI) and cannot issue if slipped through.
- [x] `dotnet test` covers economy validation gates.
## Implementation reconciliation (shipped)
- **`ContractEconomyValidation.TryValidateTemplate`** — delegates to `PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError` + `TryGetCrossRefError` for one template row against live registries.
- **`ContractGeneratorOperations.TryIssue`** — extended signature (encounter/item/skill/faction registries); economy validation after template selection, before persist.
- **`ContractGeneratorReasonCodes`:** `economy_cap_exceeded`, `invalid_reward_bundle`.
- **Tests:** `ContractEconomyValidationTests` (4 unit), `ContractGeneratorOperationsTests` (+2 deny paths), shared `ContractPrototypeTestFixtures`; all `TryIssue` call sites updated; **912** tests green.
- **Docs:** `server/README.md`, `E7_M4_ContractMissionGenerator.md`, alignment register, module dependency register.
## Technical approach
### 1. `ContractEconomyValidation` (`Game/Contracts/`)
Static helper (mirror `MoveCommandValidation` shape):
```csharp
public static bool TryValidateTemplate(
ContractTemplateRow template,
IEncounterDefinitionRegistry encounterRegistry,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IFactionDefinitionRegistry factionRegistry,
out string? reasonCode)
```
**Flow:**
1. Build single-row `Dictionary<string, ContractTemplateRow>` for the selected template id.
2. **`TryGetBandCapGateError`** — if non-null → `reasonCode = economy_cap_exceeded`, return false.
3. **`TryGetCrossRefError`** — pass `knownEncounterIds`, `knownFactionIds`, `skillDefsById` from registries (same inputs as `ContractTemplateCatalogLoader`).
4. If cross-ref error → `reasonCode = invalid_reward_bundle`, return false.
5. Otherwise return true.
Internal detail strings from gate helpers stay server-side; orchestrator exposes only stable reason codes.
### 2. `ContractGeneratorOperations.TryIssue`
After successful `TrySelectTemplate`, before `MakeDeterministicInstanceId` / `TryCreateActive`:
```csharp
if (!ContractEconomyValidation.TryValidateTemplate(
template!, encounterRegistry, itemRegistry, skillRegistry, factionRegistry, out var economyReason))
{
return Deny(economyReason!);
}
```
Extend signature with:
- `IEncounterDefinitionRegistry encounterRegistry`
- `IItemDefinitionRegistry itemRegistry`
- `ISkillDefinitionRegistry skillRegistry`
- `IFactionDefinitionRegistry factionRegistry`
Update all call sites (generator tests, completion tests, encounter tests, quest fixture test, integration tests).
### 3. Reason codes
| Code | When |
|------|------|
| **`economy_cap_exceeded`** | Item qty, skill XP, or rep grant exceeds band policy |
| **`invalid_reward_bundle`** | Unknown encounter/item/skill/faction ref or skill missing `mission_reward` allowlist |
### 4. Docs
- **`server/README.md`** — extend contract generator reason-code table; note issue-time economy lint (NEO-150).
- **`E7_M4_ContractMissionGenerator.md`** — economy validation at issue + stub: “Prototype band caps enforce Slice 4 policy; full E6.M4 `RewardParityMap` / E3.M5 balance dashboards deferred.”
- **`documentation_and_implementation_alignment.md`** — E7.M4 row note after implementation.
### 5. Parity stub (E6.M4 / E3.M5)
One short subsection under E7.M4 module doc — no new E6.M4 implementation; documents that issue-time lint is **prototype band caps only**, not equivalent-power parity.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-150-implementation-plan.md` | This plan |
| `server/NeonSprawl.Server/Game/Contracts/ContractEconomyValidation.cs` | `TryValidateTemplate` — band caps + cross-ref for one template |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractEconomyValidationTests.cs` | AAA unit tests for validation helper (at-cap, over-cap, unknown ref) |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorReasonCodes.cs` | Add `EconomyCapExceeded`, `InvalidRewardBundle` constants |
| `server/NeonSprawl.Server/Game/Contracts/ContractGeneratorOperations.cs` | Invoke `TryValidateTemplate` before persist; extend `TryIssue` signature |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsTests.cs` | Wire registries into helper; add over-cap + unknown-ref deny tests; update `TryIssue` calls |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperationsIntegrationTests.cs` | Pass registries from DI |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorPersistenceIntegrationTests.cs` | Pass registries from DI |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs` | Update `TryIssue` call sites |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs` | Update `TryIssue` call sites |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Update `TryIssue` call sites |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs` | Update `TryIssue` call sites |
| `server/README.md` | Document economy validation at issue + reason codes |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-150 economy validation + E6.M4/E3.M5 parity stub |
## Tests
| File | What it covers |
|------|----------------|
| `ContractEconomyValidationTests` | At-cap pass (prototype freeze row); over-cap deny → `economy_cap_exceeded`; unknown item id → `invalid_reward_bundle` |
| `ContractGeneratorOperationsTests` | End-to-end: tampered template in in-memory registry denies issue with correct reason codes; prototype happy path still passes |
| `ContractGeneratorOperationsIntegrationTests` | DI path with real catalog still issues successfully |
| `ContractTemplateCatalogLoaderTests` (existing) | Startup fail on over-cap catalog — AC “fails at startup (CI)”; no change expected |
| CI | `python3 scripts/validate_content.py` unchanged; `dotnet test` green |
Manual Godot QA: **none** (server policy; capstone NEO-154).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Duplicate validation vs startup load | Accept small cost — issue-time lint is explicit Slice 4 AC defense-in-depth | `adopted` |
| `TryIssue` signature churn | Update all call sites in this story; NEO-151 HTTP wrapper inherits extended signature | `adopted` |
| Client deny copy for `invalid_reward_bundle` | Defer readable copy to NEO-153; server ships stable code now | `deferred` |
| Telemetry `reward_anomaly` at economy deny | Defer comment-only hooks to NEO-152 | `deferred` |
| **NEO-151 HTTP `TryIssue` wiring** | Production DI path must pass the same four registries already used in `ContractGeneratorOperationsIntegrationTests` (`IEncounterDefinitionRegistry`, `IItemDefinitionRegistry`, `ISkillDefinitionRegistry`, `IFactionDefinitionRegistry`) — verify at NEO-151 kickoff | `pending` |
## Client counterpart
None (server policy). **NEO-153** will surface `economy_cap_exceeded` and `invalid_reward_bundle` after **NEO-151** HTTP lands.

View File

@ -0,0 +1,64 @@
# Code review — NEO-150 economy validation at issue time
**Date:** 2026-06-28
**Scope:** Branch `NEO-150-e7m4-07-economy-validation-lint-at-issue-time` — commits `039542a`..`d5de4e8` (3 commits)
**Base:** `origin/main`
**Issue:** [NEO-150](https://linear.app/neon-sprawl/issue/NEO-150) — E7M4-07 economy validation lint at issue time
## Verdict
**Approve with nits**
## Summary
This branch adds defense-in-depth economy validation at contract issue time: `ContractEconomyValidation.TryValidateTemplate` delegates to existing `PrototypeE7M4ContractCatalogRules` band-cap and cross-ref helpers, wired into `ContractGeneratorOperations.TryIssue` after template selection and before persist. Two new reason codes (`economy_cap_exceeded`, `invalid_reward_bundle`) surface stable deny semantics for downstream HTTP (NEO-151) and client copy (NEO-153).
The implementation matches the adopted kickoff plan: registry parameters on `TryIssue`, split reason codes, full loader-parity cross-ref scope, and no HTTP/client scope creep. All `TryIssue` call sites are updated; unit and integration tests cover happy path, over-cap deny, and unknown bundle ref deny. Full suite passes (911 tests). Risk is low — logic reuses proven CI/startup gates; main residual risk is per-issue registry snapshot allocation cost, acceptable at prototype scale.
## Documentation checked
| Document | Result |
|----------|--------|
| `docs/plans/NEO-150-implementation-plan.md` | **Matches** — AC checklist marked complete; shipped reconciliation aligns with diff |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-150 economy validation note + E6.M4/E3.M5 parity stub added |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row updated with E7M4-07 / NEO-150 |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M4 note references NEO-150 |
| `server/README.md` | **Matches** — issue-time economy validation paragraph + reason-code table extended |
| `docs/plans/E7M4-pre-production-backlog.md` | **Not re-read in diff** — plan cites it; backlog intent satisfied by slice scope |
Register/tracking table updates are already included in this branch.
## Blocking issues
None.
## Suggestions
1. ~~**Deduplicate prototype registry test fixtures** — `ContractEconomyValidationTests.CreatePrototypeRegistries()` and `ContractGeneratorOperationsTests.CreatePrototypeCatalogRegistries()` are nearly identical (~50 lines). Consider a shared internal test helper (e.g. `ContractPrototypeTestFixtures`) to avoid drift when prototype catalog ids change.~~ **Done.**`ContractPrototypeTestFixtures.CreateCatalogRegistries()`.
2. ~~**Broaden deny-path test matrix (optional)** — Current tests exercise item qty over cap and unknown item id only. Band-cap and cross-ref helpers also cover skill XP, reputation grants, unknown encounter, and disallowed `mission_reward` skill sources (covered at loader level in `ContractTemplateCatalogLoaderTests`). One additional `[Theory]` or a skill-Xp-over-cap case in `ContractEconomyValidationTests` would document parity without much cost.~~ **Done.**`TryValidateTemplate_ShouldDenyEconomyCapExceeded_WhenSkillXpOverBandCap`.
3. ~~**NEO-151 call-site checklist** — When HTTP lands, confirm the production DI path passes the same four registries already wired in integration tests. No code change needed here; worth a one-line note in NEO-151 plan.~~ **Done.** — handoff row in [NEO-150 plan](../plans/NEO-150-implementation-plan.md) open questions (`pending` for NEO-151 kickoff).
## Nits
- ~~Nit: `ContractEconomyValidation.TryValidateTemplate` does not null-check `template`; safe today because `TrySelectTemplate` only returns success with a non-null template, but `ArgumentNullException.ThrowIfNull(template)` would mirror the registry guards.~~ **Done.**
- ~~Nit: Issue-time validation order is band-cap → cross-ref; startup loader runs cross-ref → band-cap. Intentional per plan (cap failures get `economy_cap_exceeded`), but worth keeping in mind if both gates ever fail on the same row during debugging.~~ **Done.** — inline comment in `ContractEconomyValidation`.
- Nit: Per-issue allocation of encounter/faction hash sets and skill dictionary is fine for prototype; revisit only if issue volume becomes hot-path.
## Verification
Commands run during review:
```bash
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractEconomyValidation|FullyQualifiedName~ContractGeneratorOperations"
dotnet test NeonSprawl.sln
```
Results: 20 filtered tests passed; **911/911** full suite passed.
Author pre-merge checklist:
- [x] `dotnet test NeonSprawl.sln` green
- [ ] Merge to `main` and proceed to NEO-151 (HTTP) with extended `TryIssue` signature
- [ ] No Godot manual QA required (server policy per plan)

View File

@ -32,6 +32,10 @@ public sealed class ContractCompletionOperationsIntegrationTests
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
deps.TemplateRegistry, deps.TemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.InstanceStore, deps.InstanceStore,
deps.StandingStore, deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
@ -123,6 +127,7 @@ public sealed class ContractCompletionOperationsIntegrationTests
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(), services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(), services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(), services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(), services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(), services.GetRequiredService<IEncounterCompletionStore>(),
@ -147,6 +152,7 @@ public sealed class ContractCompletionOperationsIntegrationTests
IContractInstanceStore InstanceStore, IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore, IContractOutcomeStore OutcomeStore,
IEncounterDefinitionRegistry EncounterRegistry, IEncounterDefinitionRegistry EncounterRegistry,
IFactionDefinitionRegistry FactionRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry, IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore, IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore, IEncounterCompletionStore CompletionStore,

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -165,6 +166,10 @@ public sealed class ContractCompletionOperationsTests
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
deps.TemplateRegistry, deps.TemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.InstanceStore, deps.InstanceStore,
deps.StandingStore, deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
@ -226,6 +231,8 @@ public sealed class ContractCompletionOperationsTests
services.GetRequiredService<IContractTemplateRegistry>(), services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(), services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>(),
services.GetRequiredService<IItemDefinitionRegistry>(), services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(), services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(), services.GetRequiredService<ISkillDefinitionRegistry>(),
@ -248,6 +255,8 @@ public sealed class ContractCompletionOperationsTests
IContractTemplateRegistry TemplateRegistry, IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore, IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore, IContractOutcomeStore OutcomeStore,
IEncounterDefinitionRegistry EncounterRegistry,
IFactionDefinitionRegistry FactionRegistry,
IItemDefinitionRegistry ItemRegistry, IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore, IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry, ISkillDefinitionRegistry SkillRegistry,

View File

@ -0,0 +1,105 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractEconomyValidationTests
{
[Fact]
public void TryValidateTemplate_ShouldReturnTrue_WhenPrototypeFreezeRowAtCaps()
{
// Arrange
var template = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId];
var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries();
// Act
var ok = ContractEconomyValidation.TryValidateTemplate(
template,
registries.EncounterRegistry,
registries.ItemRegistry,
registries.SkillRegistry,
registries.FactionRegistry,
out var reasonCode);
// Assert
Assert.True(ok);
Assert.Null(reasonCode);
}
[Fact]
public void TryValidateTemplate_ShouldDenyEconomyCapExceeded_WhenItemQuantityOverBandCap()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId];
var overCapTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("scrap_metal_bulk", 11)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries();
// Act
var ok = ContractEconomyValidation.TryValidateTemplate(
overCapTemplate,
registries.EncounterRegistry,
registries.ItemRegistry,
registries.SkillRegistry,
registries.FactionRegistry,
out var reasonCode);
// Assert
Assert.False(ok);
Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, reasonCode);
}
[Fact]
public void TryValidateTemplate_ShouldDenyEconomyCapExceeded_WhenSkillXpOverBandCap()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId];
var overCapTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
prototype.CompletionRewardBundle.ItemGrants,
[new QuestSkillXpGrantRow("salvage", 26)],
prototype.CompletionRewardBundle.ReputationGrants),
};
var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries();
// Act
var ok = ContractEconomyValidation.TryValidateTemplate(
overCapTemplate,
registries.EncounterRegistry,
registries.ItemRegistry,
registries.SkillRegistry,
registries.FactionRegistry,
out var reasonCode);
// Assert
Assert.False(ok);
Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, reasonCode);
}
[Fact]
public void TryValidateTemplate_ShouldDenyInvalidRewardBundle_WhenItemIdUnknown()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[ContractPrototypeTestFixtures.TemplateId];
var unknownItemTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("unknown_item_xyz", 1)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var registries = ContractPrototypeTestFixtures.CreateCatalogRegistries();
// Act
var ok = ContractEconomyValidation.TryValidateTemplate(
unknownItemTemplate,
registries.EncounterRegistry,
registries.ItemRegistry,
registries.SkillRegistry,
registries.FactionRegistry,
out var reasonCode);
// Assert
Assert.False(ok);
Assert.Equal(ContractGeneratorReasonCodes.InvalidRewardBundle, reasonCode);
}
}

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;
@ -27,12 +30,20 @@ public sealed class ContractGeneratorOperationsIntegrationTests
TemplateId, TemplateId,
SeedBucket); SeedBucket);
// Act // Act
var encounterRegistry = scope.ServiceProvider.GetRequiredService<IEncounterDefinitionRegistry>();
var itemRegistry = scope.ServiceProvider.GetRequiredService<IItemDefinitionRegistry>();
var skillRegistry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var factionRegistry = scope.ServiceProvider.GetRequiredService<IFactionDefinitionRegistry>();
var result = ContractGeneratorOperations.TryIssue( var result = ContractGeneratorOperations.TryIssue(
PlayerId, PlayerId,
TemplateId, TemplateId,
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
templateRegistry, templateRegistry,
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry,
instanceStore, instanceStore,
standingStore, standingStore,
timeProvider); timeProvider);

View File

@ -1,7 +1,10 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts; namespace NeonSprawl.Server.Tests.Game.Contracts;
@ -206,6 +209,10 @@ public sealed class ContractGeneratorOperationsTests
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
templateRegistry, templateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
store, store,
standingStore, standingStore,
deps.TimeProvider); deps.TimeProvider);
@ -252,6 +259,50 @@ public sealed class ContractGeneratorOperationsTests
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode); Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
} }
[Fact]
public void TryIssue_ShouldDenyEconomyCapExceeded_WhenItemQuantityOverBandCap()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var overCapTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("scrap_metal_bulk", 11)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var deps = CreateDependencies(overCapTemplate);
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidRewardBundle_WhenItemIdUnknown()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var unknownItemTemplate = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("unknown_item_xyz", 1)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var deps = CreateDependencies(unknownItemTemplate);
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidRewardBundle, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
private static ContractIssueOperationResult TryIssue( private static ContractIssueOperationResult TryIssue(
GeneratorTestDependencies deps, GeneratorTestDependencies deps,
string? playerId = PlayerId, string? playerId = PlayerId,
@ -264,6 +315,10 @@ public sealed class ContractGeneratorOperationsTests
seedBucket, seedBucket,
zoneDifficultyBand, zoneDifficultyBand,
deps.TemplateRegistry, deps.TemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.InstanceStore, deps.InstanceStore,
deps.StandingStore, deps.StandingStore,
deps.TimeProvider); deps.TimeProvider);
@ -281,8 +336,13 @@ public sealed class ContractGeneratorOperationsTests
var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
var instanceStore = new InMemoryContractInstanceStore(positionOptions); var instanceStore = new InMemoryContractInstanceStore(positionOptions);
var standingStore = CreateStandingStore(); var standingStore = CreateStandingStore();
var catalogRegistries = ContractPrototypeTestFixtures.CreateCatalogRegistries();
return new GeneratorTestDependencies( return new GeneratorTestDependencies(
templateRegistry, templateRegistry,
catalogRegistries.EncounterRegistry,
catalogRegistries.ItemRegistry,
catalogRegistries.SkillRegistry,
catalogRegistries.FactionRegistry,
instanceStore, instanceStore,
standingStore, standingStore,
new FakeTimeProvider(IssuedAt)); new FakeTimeProvider(IssuedAt));
@ -307,6 +367,10 @@ public sealed class ContractGeneratorOperationsTests
private readonly record struct GeneratorTestDependencies( private readonly record struct GeneratorTestDependencies(
IContractTemplateRegistry TemplateRegistry, IContractTemplateRegistry TemplateRegistry,
IEncounterDefinitionRegistry EncounterRegistry,
IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IFactionDefinitionRegistry FactionRegistry,
IContractInstanceStore InstanceStore, IContractInstanceStore InstanceStore,
IFactionStandingStore StandingStore, IFactionStandingStore StandingStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState;
@ -36,6 +39,10 @@ public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegra
SeedBucket, SeedBucket,
zoneDifficultyBand: null, zoneDifficultyBand: null,
services.GetRequiredService<IContractTemplateRegistry>(), services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>(),
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IFactionStandingStore>(), services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<TimeProvider>()); services.GetRequiredService<TimeProvider>());

View File

@ -0,0 +1,75 @@
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.Contracts;
/// <summary>
/// Shared in-memory prototype definition registries for contract generator and economy tests (NEO-150).
/// Keep ids aligned with <see cref="Game.Contracts.PrototypeE7M4ContractCatalogRules"/> freeze table.
/// </summary>
internal static class ContractPrototypeTestFixtures
{
public const string TemplateId = "prototype_contract_clear_combat_pocket";
public const string EncounterId = "prototype_combat_pocket";
public const string GridOperatorsFactionId = "prototype_faction_grid_operators";
public static ContractPrototypeCatalogRegistries CreateCatalogRegistries()
{
var encounterRows = new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal)
{
[EncounterId] = new(
EncounterId,
"Prototype Combat Pocket",
"defeat_all_targets",
["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
"prototype_combat_pocket_clear"),
};
var encounterRegistry = new EncounterDefinitionRegistry(
new EncounterDefinitionCatalog("/tmp/encounters", encounterRows, catalogJsonFileCount: 1));
var itemRows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var itemRegistry = new ItemDefinitionRegistry(
new ItemDefinitionCatalog("/tmp/items", itemRows, catalogJsonFileCount: 1));
var skillRows = new Dictionary<string, SkillDefRow>(StringComparer.Ordinal)
{
["salvage"] = new("salvage", "gather", "Salvage", ["activity", "mission_reward"]),
["refine"] = new("refine", "process", "Refine", ["activity", "mission_reward", "trainer"]),
["intrusion"] = new("intrusion", "tech", "Intrusion", ["activity", "mission_reward", "book_or_item"]),
};
var skillRegistry = new SkillDefinitionRegistry(
new SkillDefinitionCatalog("/tmp/skills", skillRows, catalogJsonFileCount: 1));
var factionRows = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId],
};
var factionRegistry = new FactionDefinitionRegistry(
new FactionDefinitionCatalog("/tmp/factions", factionRows, catalogJsonFileCount: 1));
return new ContractPrototypeCatalogRegistries(
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry);
}
}
internal sealed record ContractPrototypeCatalogRegistries(
IEncounterDefinitionRegistry EncounterRegistry,
IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IFactionDefinitionRegistry FactionRegistry);

View File

@ -28,6 +28,10 @@ public sealed class EncounterCompletionOperationsTests
"2026-06-22", "2026-06-22",
zoneDifficultyBand: null, zoneDifficultyBand: null,
deps.ContractTemplateRegistry, deps.ContractTemplateRegistry,
deps.EncounterRegistry,
deps.ItemRegistry,
deps.SkillRegistry,
deps.FactionRegistry,
deps.ContractInstanceStore, deps.ContractInstanceStore,
deps.StandingStore, deps.StandingStore,
TimeProvider.System); TimeProvider.System);
@ -513,7 +517,8 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IReputationDeltaStore>(), services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(), services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(), services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>()); services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IFactionDefinitionRegistry>());
} }
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -564,7 +569,8 @@ public sealed class EncounterCompletionOperationsTests
IReputationDeltaStore AuditStore, IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore, IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore, IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry); IContractTemplateRegistry ContractTemplateRegistry,
IFactionDefinitionRegistry FactionRegistry);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{ {

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
@ -291,12 +292,18 @@ public sealed class QuestFixtureApiTests
var timeProvider = factory.Services.GetRequiredService<TimeProvider>(); var timeProvider = factory.Services.GetRequiredService<TimeProvider>();
const string templateId = "prototype_contract_clear_combat_pocket"; const string templateId = "prototype_contract_clear_combat_pocket";
const string encounterId = "prototype_combat_pocket"; const string encounterId = "prototype_combat_pocket";
var encounterRegistry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var factionRegistry = factory.Services.GetRequiredService<IFactionDefinitionRegistry>();
var issued = ContractGeneratorOperations.TryIssue( var issued = ContractGeneratorOperations.TryIssue(
DevPlayer, DevPlayer,
templateId, templateId,
"2026-06-22", "2026-06-22",
zoneDifficultyBand: null, zoneDifficultyBand: null,
templateRegistry, templateRegistry,
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry,
instanceStore, instanceStore,
standingStore, standingStore,
timeProvider); timeProvider);

View File

@ -0,0 +1,65 @@
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Issue-time economy and reward-bundle validation for contract templates (NEO-150).
/// Mirrors startup catalog gates for defense in depth at <see cref="ContractGeneratorOperations.TryIssue"/>.
/// </summary>
public static class ContractEconomyValidation
{
/// <summary>
/// Validates band caps and cross-refs for one template against live registries.
/// </summary>
public static bool TryValidateTemplate(
ContractTemplateRow template,
IEncounterDefinitionRegistry encounterRegistry,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IFactionDefinitionRegistry factionRegistry,
out string? reasonCode)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(encounterRegistry);
ArgumentNullException.ThrowIfNull(itemRegistry);
ArgumentNullException.ThrowIfNull(skillRegistry);
ArgumentNullException.ThrowIfNull(factionRegistry);
reasonCode = null;
var rowsById = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[template.Id] = template,
};
// Band-cap before cross-ref so deny maps to economy_cap_exceeded (startup loader runs cross-ref first).
if (PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rowsById) is not null)
{
reasonCode = ContractGeneratorReasonCodes.EconomyCapExceeded;
return false;
}
var knownEncounterIds = encounterRegistry.GetDefinitionsInIdOrder()
.Select(static d => d.Id)
.ToHashSet(StringComparer.Ordinal);
var knownFactionIds = factionRegistry.GetDefinitionsInIdOrder()
.Select(static d => d.Id)
.ToHashSet(StringComparer.Ordinal);
var skillDefsById = skillRegistry.GetDefinitionsInIdOrder()
.ToDictionary(static d => d.Id, StringComparer.Ordinal);
if (PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
rowsById,
knownEncounterIds,
knownFactionIds,
skillDefsById) is not null)
{
reasonCode = ContractGeneratorReasonCodes.InvalidRewardBundle;
return false;
}
return true;
}
}

View File

@ -1,10 +1,13 @@
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts; namespace NeonSprawl.Server.Game.Contracts;
/// <summary> /// <summary>
/// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147). /// Server-authoritative contract issuance from template catalog + seed inputs (NEO-147).
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152). /// HTTP: E7M4-08 (NEO-151). Economy validation: E7M4-07 (NEO-150). Telemetry hook sites: E7M4-09 (NEO-152).
/// </summary> /// </summary>
public static class ContractGeneratorOperations public static class ContractGeneratorOperations
{ {
@ -17,6 +20,10 @@ public static class ContractGeneratorOperations
string seedBucket, string seedBucket,
int? zoneDifficultyBand, int? zoneDifficultyBand,
IContractTemplateRegistry templateRegistry, IContractTemplateRegistry templateRegistry,
IEncounterDefinitionRegistry encounterRegistry,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IFactionDefinitionRegistry factionRegistry,
IContractInstanceStore instanceStore, IContractInstanceStore instanceStore,
IFactionStandingStore standingStore, IFactionStandingStore standingStore,
TimeProvider timeProvider) TimeProvider timeProvider)
@ -52,6 +59,17 @@ public static class ContractGeneratorOperations
return Deny(denyReason!); return Deny(denyReason!);
} }
if (!ContractEconomyValidation.TryValidateTemplate(
template!,
encounterRegistry,
itemRegistry,
skillRegistry,
factionRegistry,
out var economyReason))
{
return Deny(economyReason!);
}
var instanceId = ContractInstanceIds.MakeDeterministicInstanceId( var instanceId = ContractInstanceIds.MakeDeterministicInstanceId(
normalizedPlayerId, normalizedPlayerId,
template!.Id, template!.Id,

View File

@ -12,4 +12,8 @@ public static class ContractGeneratorReasonCodes
public const string NoEligibleTemplate = "no_eligible_template"; public const string NoEligibleTemplate = "no_eligible_template";
public const string UnknownTemplate = "unknown_template"; public const string UnknownTemplate = "unknown_template";
public const string EconomyCapExceeded = "economy_cap_exceeded";
public const string InvalidRewardBundle = "invalid_reward_bundle";
} }

View File

@ -298,6 +298,8 @@ Completed instances are immutable (no status regression). Player, template, and
- **Omitted/empty `templateId`:** first template from **`GetDefinitionsInIdOrder()`** matching band + standing. - **Omitted/empty `templateId`:** first template from **`GetDefinitionsInIdOrder()`** matching band + standing.
- Faction floor uses **`FactionGateOperations`** on the template **`minFactionStanding`** row. - Faction floor uses **`FactionGateOperations`** on the template **`minFactionStanding`** row.
**Economy validation (NEO-150):** after template selection, **`ContractEconomyValidation.TryValidateTemplate`** re-checks band caps and reward-bundle cross-refs against live encounter/item/skill/faction registries (same rules as startup catalog load). Fail-closed before persist.
**Deterministic instance id:** **`ContractInstanceIds.MakeDeterministicInstanceId`** — SHA-256 of `{playerId}|{templateId}|{seedBucket}` (normalized) → **`ci_{32 lowercase hex}`**. **Deterministic instance id:** **`ContractInstanceIds.MakeDeterministicInstanceId`** — SHA-256 of `{playerId}|{templateId}|{seedBucket}` (normalized) → **`ci_{32 lowercase hex}`**.
**Reason codes:** **Reason codes:**
@ -309,10 +311,12 @@ Completed instances are immutable (no status regression). Player, template, and
| **`active_contract_exists`** | Player already has an active instance | | **`active_contract_exists`** | Player already has an active instance |
| **`unknown_template`** | Explicit template id not in registry | | **`unknown_template`** | Explicit template id not in registry |
| **`no_eligible_template`** | No template matches band + standing filters | | **`no_eligible_template`** | No template matches band + standing filters |
| **`economy_cap_exceeded`** | Item qty, skill XP, or rep grant exceeds band policy |
| **`invalid_reward_bundle`** | Unknown encounter/item/skill/faction ref or disallowed skill XP source |
**Result:** **`ContractIssueOperationResult`** — `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, **`encounterTemplateId`** from the selected template. **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). HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md), [NEO-150 implementation plan](../../docs/plans/NEO-150-implementation-plan.md).
## Contract completion operations (NEO-149) ## Contract completion operations (NEO-149)