Compare commits
No commits in common. "e0d0be65c6a4c51bfc458ec1f0ea8533dcbc046a" and "177b316efab0bb09d634a8c297956fcf153508a6" have entirely different histories.
e0d0be65c6
...
177b316efa
|
|
@ -59,10 +59,6 @@ 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
|
|
@ -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)); **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).
|
**E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); **E7M4-03 / NEO-146** contract instance + outcome stores landed ([NEO-146 plan](../../plans/NEO-146-implementation-plan.md)); **E7M4-04 / NEO-147** contract generator **`TryIssue`** landed ([NEO-147 plan](../../plans/NEO-147-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154).
|
||||||
|
|
||||||
### Epic 8 — Social / Guild
|
### Epic 8 — Social / Guild
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
# 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,10 +32,6 @@ 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);
|
||||||
|
|
@ -127,7 +123,6 @@ 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>(),
|
||||||
|
|
@ -152,7 +147,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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;
|
||||||
|
|
@ -166,10 +165,6 @@ 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);
|
||||||
|
|
@ -231,8 +226,6 @@ 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>(),
|
||||||
|
|
@ -255,8 +248,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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,8 +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.Skills;
|
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
||||||
|
|
||||||
|
|
@ -30,20 +27,12 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -209,10 +206,6 @@ 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);
|
||||||
|
|
@ -259,50 +252,6 @@ 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,
|
||||||
|
|
@ -315,10 +264,6 @@ 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);
|
||||||
|
|
@ -336,13 +281,8 @@ 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));
|
||||||
|
|
@ -367,10 +307,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +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.Skills;
|
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||||
|
|
||||||
|
|
@ -39,10 +36,6 @@ 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>());
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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,10 +28,6 @@ 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);
|
||||||
|
|
@ -517,8 +513,7 @@ 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)
|
||||||
|
|
@ -569,8 +564,7 @@ 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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -292,18 +291,12 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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,13 +1,10 @@
|
||||||
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). Economy validation: E7M4-07 (NEO-150). Telemetry hook sites: E7M4-09 (NEO-152).
|
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ContractGeneratorOperations
|
public static class ContractGeneratorOperations
|
||||||
{
|
{
|
||||||
|
|
@ -20,10 +17,6 @@ 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)
|
||||||
|
|
@ -59,17 +52,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,4 @@ 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";
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,8 +298,6 @@ 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:**
|
||||||
|
|
@ -311,12 +309,10 @@ 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.
|
||||||
|
|
||||||
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).
|
Economy band-cap lint at issue time lands in **NEO-150** (E7M4-07). HTTP **`POST …/contracts/issue`** lands in **NEO-151** (E7M4-08). Plan: [NEO-147 implementation plan](../../docs/plans/NEO-147-implementation-plan.md).
|
||||||
|
|
||||||
## Contract completion operations (NEO-149)
|
## Contract completion operations (NEO-149)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue