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 timemain
commit
e0d0be65c6
|
|
@ -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).
|
||||
|
||||
**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 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
|
|
@ -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.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -32,6 +32,10 @@ public sealed class ContractCompletionOperationsIntegrationTests
|
|||
SeedBucket,
|
||||
zoneDifficultyBand: null,
|
||||
deps.TemplateRegistry,
|
||||
deps.EncounterRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.SkillRegistry,
|
||||
deps.FactionRegistry,
|
||||
deps.InstanceStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
|
@ -123,6 +127,7 @@ public sealed class ContractCompletionOperationsIntegrationTests
|
|||
services.GetRequiredService<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IContractOutcomeStore>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>(),
|
||||
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
|
||||
services.GetRequiredService<IEncounterProgressStore>(),
|
||||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
|
|
@ -147,6 +152,7 @@ public sealed class ContractCompletionOperationsIntegrationTests
|
|||
IContractInstanceStore InstanceStore,
|
||||
IContractOutcomeStore OutcomeStore,
|
||||
IEncounterDefinitionRegistry EncounterRegistry,
|
||||
IFactionDefinitionRegistry FactionRegistry,
|
||||
IRewardTableDefinitionRegistry RewardTableRegistry,
|
||||
IEncounterProgressStore ProgressStore,
|
||||
IEncounterCompletionStore CompletionStore,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
|
@ -165,6 +166,10 @@ public sealed class ContractCompletionOperationsTests
|
|||
SeedBucket,
|
||||
zoneDifficultyBand: null,
|
||||
deps.TemplateRegistry,
|
||||
deps.EncounterRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.SkillRegistry,
|
||||
deps.FactionRegistry,
|
||||
deps.InstanceStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
|
@ -226,6 +231,8 @@ public sealed class ContractCompletionOperationsTests
|
|||
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||
services.GetRequiredService<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IContractOutcomeStore>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
|
|
@ -248,6 +255,8 @@ public sealed class ContractCompletionOperationsTests
|
|||
IContractTemplateRegistry TemplateRegistry,
|
||||
IContractInstanceStore InstanceStore,
|
||||
IContractOutcomeStore OutcomeStore,
|
||||
IEncounterDefinitionRegistry EncounterRegistry,
|
||||
IFactionDefinitionRegistry FactionRegistry,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
using NeonSprawl.Server.Game.Contracts;
|
||||
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;
|
||||
|
||||
|
|
@ -27,12 +30,20 @@ public sealed class ContractGeneratorOperationsIntegrationTests
|
|||
TemplateId,
|
||||
SeedBucket);
|
||||
// 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(
|
||||
PlayerId,
|
||||
TemplateId,
|
||||
SeedBucket,
|
||||
zoneDifficultyBand: null,
|
||||
templateRegistry,
|
||||
encounterRegistry,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
factionRegistry,
|
||||
instanceStore,
|
||||
standingStore,
|
||||
timeProvider);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||
|
||||
|
|
@ -206,6 +209,10 @@ public sealed class ContractGeneratorOperationsTests
|
|||
SeedBucket,
|
||||
zoneDifficultyBand: null,
|
||||
templateRegistry,
|
||||
deps.EncounterRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.SkillRegistry,
|
||||
deps.FactionRegistry,
|
||||
store,
|
||||
standingStore,
|
||||
deps.TimeProvider);
|
||||
|
|
@ -252,6 +259,50 @@ public sealed class ContractGeneratorOperationsTests
|
|||
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(
|
||||
GeneratorTestDependencies deps,
|
||||
string? playerId = PlayerId,
|
||||
|
|
@ -264,6 +315,10 @@ public sealed class ContractGeneratorOperationsTests
|
|||
seedBucket,
|
||||
zoneDifficultyBand,
|
||||
deps.TemplateRegistry,
|
||||
deps.EncounterRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.SkillRegistry,
|
||||
deps.FactionRegistry,
|
||||
deps.InstanceStore,
|
||||
deps.StandingStore,
|
||||
deps.TimeProvider);
|
||||
|
|
@ -281,8 +336,13 @@ public sealed class ContractGeneratorOperationsTests
|
|||
var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
||||
var instanceStore = new InMemoryContractInstanceStore(positionOptions);
|
||||
var standingStore = CreateStandingStore();
|
||||
var catalogRegistries = ContractPrototypeTestFixtures.CreateCatalogRegistries();
|
||||
return new GeneratorTestDependencies(
|
||||
templateRegistry,
|
||||
catalogRegistries.EncounterRegistry,
|
||||
catalogRegistries.ItemRegistry,
|
||||
catalogRegistries.SkillRegistry,
|
||||
catalogRegistries.FactionRegistry,
|
||||
instanceStore,
|
||||
standingStore,
|
||||
new FakeTimeProvider(IssuedAt));
|
||||
|
|
@ -307,6 +367,10 @@ public sealed class ContractGeneratorOperationsTests
|
|||
|
||||
private readonly record struct GeneratorTestDependencies(
|
||||
IContractTemplateRegistry TemplateRegistry,
|
||||
IEncounterDefinitionRegistry EncounterRegistry,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IFactionDefinitionRegistry FactionRegistry,
|
||||
IContractInstanceStore InstanceStore,
|
||||
IFactionStandingStore StandingStore,
|
||||
TimeProvider TimeProvider);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
|
|
@ -36,6 +39,10 @@ public sealed class ContractGeneratorPersistenceIntegrationTests(PostgresIntegra
|
|||
SeedBucket,
|
||||
zoneDifficultyBand: null,
|
||||
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>(),
|
||||
services.GetRequiredService<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IFactionStandingStore>(),
|
||||
services.GetRequiredService<TimeProvider>());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -28,6 +28,10 @@ public sealed class EncounterCompletionOperationsTests
|
|||
"2026-06-22",
|
||||
zoneDifficultyBand: null,
|
||||
deps.ContractTemplateRegistry,
|
||||
deps.EncounterRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.SkillRegistry,
|
||||
deps.FactionRegistry,
|
||||
deps.ContractInstanceStore,
|
||||
deps.StandingStore,
|
||||
TimeProvider.System);
|
||||
|
|
@ -513,7 +517,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
services.GetRequiredService<IReputationDeltaStore>(),
|
||||
services.GetRequiredService<IContractInstanceStore>(),
|
||||
services.GetRequiredService<IContractOutcomeStore>(),
|
||||
services.GetRequiredService<IContractTemplateRegistry>());
|
||||
services.GetRequiredService<IContractTemplateRegistry>(),
|
||||
services.GetRequiredService<IFactionDefinitionRegistry>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -564,7 +569,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
IReputationDeltaStore AuditStore,
|
||||
IContractInstanceStore ContractInstanceStore,
|
||||
IContractOutcomeStore ContractOutcomeStore,
|
||||
IContractTemplateRegistry ContractTemplateRegistry);
|
||||
IContractTemplateRegistry ContractTemplateRegistry,
|
||||
IFactionDefinitionRegistry FactionRegistry);
|
||||
|
||||
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using NeonSprawl.Server.Game.Contracts;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Factions;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
|
|
@ -291,12 +292,18 @@ public sealed class QuestFixtureApiTests
|
|||
var timeProvider = factory.Services.GetRequiredService<TimeProvider>();
|
||||
const string templateId = "prototype_contract_clear_combat_pocket";
|
||||
const string encounterId = "prototype_combat_pocket";
|
||||
var encounterRegistry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var factionRegistry = factory.Services.GetRequiredService<IFactionDefinitionRegistry>();
|
||||
var issued = ContractGeneratorOperations.TryIssue(
|
||||
DevPlayer,
|
||||
templateId,
|
||||
"2026-06-22",
|
||||
zoneDifficultyBand: null,
|
||||
templateRegistry,
|
||||
encounterRegistry,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
factionRegistry,
|
||||
instanceStore,
|
||||
standingStore,
|
||||
timeProvider);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
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>
|
||||
/// 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>
|
||||
public static class ContractGeneratorOperations
|
||||
{
|
||||
|
|
@ -17,6 +20,10 @@ public static class ContractGeneratorOperations
|
|||
string seedBucket,
|
||||
int? zoneDifficultyBand,
|
||||
IContractTemplateRegistry templateRegistry,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
ISkillDefinitionRegistry skillRegistry,
|
||||
IFactionDefinitionRegistry factionRegistry,
|
||||
IContractInstanceStore instanceStore,
|
||||
IFactionStandingStore standingStore,
|
||||
TimeProvider timeProvider)
|
||||
|
|
@ -52,6 +59,17 @@ public static class ContractGeneratorOperations
|
|||
return Deny(denyReason!);
|
||||
}
|
||||
|
||||
if (!ContractEconomyValidation.TryValidateTemplate(
|
||||
template!,
|
||||
encounterRegistry,
|
||||
itemRegistry,
|
||||
skillRegistry,
|
||||
factionRegistry,
|
||||
out var economyReason))
|
||||
{
|
||||
return Deny(economyReason!);
|
||||
}
|
||||
|
||||
var instanceId = ContractInstanceIds.MakeDeterministicInstanceId(
|
||||
normalizedPlayerId,
|
||||
template!.Id,
|
||||
|
|
|
|||
|
|
@ -12,4 +12,8 @@ public static class ContractGeneratorReasonCodes
|
|||
public const string NoEligibleTemplate = "no_eligible_template";
|
||||
|
||||
public const string UnknownTemplate = "unknown_template";
|
||||
|
||||
public const string EconomyCapExceeded = "economy_cap_exceeded";
|
||||
|
||||
public const string InvalidRewardBundle = "invalid_reward_bundle";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@ Completed instances are immutable (no status regression). Player, template, and
|
|||
- **Omitted/empty `templateId`:** first template from **`GetDefinitionsInIdOrder()`** matching band + standing.
|
||||
- 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}`**.
|
||||
|
||||
**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 |
|
||||
| **`unknown_template`** | Explicit template id not in registry |
|
||||
| **`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.
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue