neon-sprawl/docs/plans/NEO-150-implementation-plan.md

11 KiB

NEO-150 — E7M4-07: Economy validation lint at issue time

Linear: NEO-150
Branch: NEO-150-e7m4-07-economy-validation-lint-at-issue-time
Backlog: E7M4-pre-production-backlog.mdE7M4-07
Module: E7_M4_ContractMissionGenerator.md
Pattern: NEO-145-implementation-plan.md (CI-parity gates + PrototypeE7M4ContractCatalogRules); NEO-147-implementation-plan.md (ContractGeneratorOperations.TryIssue orchestrator)
Precursor: NEO-147 DoneContractGeneratorOperations.TryIssue (on main)
Blocks: 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? DelegateContractEconomyValidation.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

  • Template at freeze caps issues successfully.
  • Tampered catalog row over caps fails at startup (CI) and cannot issue if slipped through.
  • 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):

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:

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.