Merge pull request #192 from ViPro-Technologies/NEO-150-e7m4-07-economy-validation-lint-at-issue-time

NEO-150: E7M4-07 economy validation lint at issue time
main
VinPropane 2026-06-28 10:07:33 -04:00 committed by GitHub
commit e0d0be65c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 627 additions and 6 deletions

View File

@ -59,6 +59,10 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147).
**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

View File

@ -118,7 +118,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete.
| E7.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

View File

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

View File

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

View File

@ -32,6 +32,10 @@ public sealed class ContractCompletionOperationsIntegrationTests
SeedBucket,
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,

View File

@ -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,

View File

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

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.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);

View File

@ -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);

View File

@ -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>());

View File

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

View File

@ -28,6 +28,10 @@ public sealed class EncounterCompletionOperationsTests
"2026-06-22",
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
{

View File

@ -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);

View File

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

View File

@ -1,10 +1,13 @@
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.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,

View File

@ -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";
}

View File

@ -298,6 +298,8 @@ Completed instances are immutable (no status regression). Player, template, and
- **Omitted/empty `templateId`:** first template from **`GetDefinitionsInIdOrder()`** matching band + standing.
- 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)