Merge pull request #190 from ViPro-Technologies/NEO-149-e7m4-06-contract-completion-encounter-wiring

NEO-149: E7M4-06 contract completion on encounter clear
pull/191/head
VinPropane 2026-06-27 22:05:41 -04:00 committed by GitHub
commit 2cf8bcd0b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1350 additions and 20 deletions

View File

@ -0,0 +1,35 @@
meta {
name: Reset contract instance via quest fixture (NEO-149)
type: http
seq: 2
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/__dev/quest-fixture
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"resetContractInstanceIds": [
"ci_00000000000000000000000000000000"
]
}
}
docs {
NEO-149: quest-fixture accepts resetContractInstanceIds — clears contract instance, contract_completion delivery row, and outcome audit for each id (idempotent when absent).
Full issue → clear → GET Bruno loop lands in NEO-151 when contract HTTP ships.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("applied true", function () {
expect(res.getBody().applied).to.equal(true);
});
}

View File

@ -5,7 +5,7 @@ meta {
}
docs {
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). No inventory POST shortcuts for spine materials.
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). Deny envelopes return empty inputsConsumed/outputsGranted arrays. No inventory POST shortcuts for spine materials.
}
script:pre-request {

View File

@ -66,6 +66,20 @@ async function resetPrototypeFactionStanding(bru) {
}
}
async function resetContractInstances(bru, contractInstanceIds) {
const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru);
const response = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
{ schemaVersion: 1, resetContractInstanceIds: contractInstanceIds },
jsonHeaders,
);
if (response.status !== 200 || response.data?.applied !== true) {
throw new Error(
`quest-fixture resetContractInstanceIds failed: ${response.status} ${JSON.stringify(response.data)}`,
);
}
}
async function assertAllPrototypeQuestsNotStarted(bru) {
const { baseUrl, playerId } = resolveConfig(bru);
const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, {
@ -111,6 +125,7 @@ module.exports = {
resetGatherIntroQuestProgress,
resetGatherIntroSpine,
resetPrototypeFactionStanding,
resetContractInstances,
clearInventory,
assertAllPrototypeQuestsNotStarted,
ALL_PROTOTYPE_QUEST_IDS,

View File

@ -61,6 +61,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**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).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,179 @@
# NEO-149 — E7M4-06: ContractCompletionOperations + encounter wiring
**Linear:** [NEO-149](https://linear.app/neon-sprawl/issue/NEO-149)
**Branch:** `NEO-149-e7m4-06-contract-completion-encounter-wiring`
**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-06**
**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md)
**Pattern:** [NEO-128-implementation-plan.md](NEO-128-implementation-plan.md) — deliver-then-mark + idempotent replay; [NEO-147-implementation-plan.md](NEO-147-implementation-plan.md) — `ContractGeneratorOperations` orchestrator shape; [NEO-148-implementation-plan.md](NEO-148-implementation-plan.md) — `TryDeliverContractCompletion` + outcome audit split
**Precursors:** [NEO-147](https://linear.app/neon-sprawl/issue/NEO-147) **`Done`** — `ContractGeneratorOperations.TryIssue`; [NEO-148](https://linear.app/neon-sprawl/issue/NEO-148) **`Done`** — `RewardRouterOperations.TryDeliverContractCompletion`
**Blocks:** [NEO-151](https://linear.app/neon-sprawl/issue/NEO-151) (E7M4-08 HTTP contract projections + Bruno issue/clear loop)
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153), [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) (blocked by E7M4-08; no Godot work in this story)
## Goal
Encounter clear completes a matching **active** contract instance and triggers the template **`completionRewardBundle`** payout. First-time encounter loot (E5.M3) remains unchanged and runs **before** contract payout.
## Kickoff clarifications
**No blocking decisions needed.** Linear AC, [E7M4-06 backlog scope](E7M4-pre-production-backlog.md#e7m4-06--contractcompletionoperations--encounter-wiring), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-128/NEO-147/NEO-148 code settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Grant order vs encounter loot | **Encounter loot first**, contract payout second — contract wiring runs only after `TryCompleteAndGrant` commits encounter grants + completion mark | Backlog E7M4-06 in-scope wiring note; `EncounterCompletionOperations` already applies reward-table grants before downstream hooks |
| Deliver-then-mark | **`TryDeliverContractCompletion`** then **`IContractInstanceStore.TryMarkComplete`**; on delivery deny, instance stays **`active`** | NEO-128 quest precedent; README reward router § contract wiring note |
| Outcome audit | **`IContractOutcomeStore.TryAppend`** after successful delivery + mark; grant snapshots from **`RewardDeliveryResult`** / **`RewardDeliveryEvent`** | NEO-146 open-questions table; outcome store requires existing instance row |
| Encounter template match | Compare cleared **`encounterId`** to template **`encounterTemplateId`** on the player's active instance | Backlog “match encounterTemplateId”; prototype binds **`prototype_combat_pocket`** |
| No active / wrong encounter | **Structured no-op success** — do not fail encounter completion | Mirror quest wiring best-effort; encounter already committed |
| Idempotent replay | When instance already **`completed`**, return success without re-delivering (router **`already_delivered`** path if invoked) | NEO-128 + store **`TryMarkComplete`** replay semantics |
| Wiring call site | **`EncounterCompletionOperations.TryCompleteAndGrant`** after **`QuestObjectiveWiring.TryProcessEncounterCompleteSuccess`** | Same hook point as quest credit; `EncounterCombatWiring` unchanged except extended dependency pass-through |
| Best-effort | Contract deny does **not** roll back encounter loot or completion mark | Encounter grants committed before contract hook; quest wiring precedent |
| Bruno smoke (`issue → clear → GET`) | **Deferred to NEO-151** — no contract HTTP routes yet | E7M4-08 scope; this story covers orchestrator + encounter wiring + integration tests |
| Contract fixture reset | Add contract delivery **`TryClear`** helper for Bruno/dev loops (NEO-148 deferred item) | NEO-148 plan open questions |
| Telemetry | **Comment-only** `contract_complete` hook in orchestrator — E7M4-09 / NEO-152 | Backlog E7M4-09 |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- **`ContractCompletionOperations.TryCompleteOnEncounterClear`** static orchestrator in `Game/Contracts/`.
- **`ContractCompletionReasonCodes`** + **`ContractCompletionOperationResult`** envelope.
- Wire from **`EncounterCompletionOperations`** success path (after encounter loot + quest wiring).
- Pass contract dependencies through **`EncounterCombatWiring`** → **`AbilityCastApi`** (mirror quest store injection).
- Flow: resolve active instance → match encounter → deliver bundle → mark instance **`completed`** → append **`ContractOutcomeRow`**.
- Integration tests: issue → encounter clear → instance **`completed`** + delivery row + outcome row; clear without contract unchanged; wrong encounter no-op; encounter first-time loot idempotency unchanged.
- Extend **`QuestFixtureOperations`** (or adjacent dev fixture) with contract delivery **`TryClear`** for test/Bruno reset loops.
- `server/README.md` contract completion operations section; E7.M4 module anchor.
**Out of scope (from Linear + backlog):**
- HTTP **`POST …/contracts/issue`**, contract GET projections (NEO-151 / E7M4-08).
- Bruno smoke requiring HTTP (NEO-151).
- **`ContractEconomyValidation`** at issue (NEO-150 / E7M4-07).
- Godot HUD (NEO-153 / NEO-154).
- E9.M1 telemetry ingest (comment-only hook sites optional).
**Client counterpart:** [NEO-153](https://linear.app/neon-sprawl/issue/NEO-153), [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) — server HTTP must land in NEO-151 first.
## Acceptance criteria checklist
- [x] Active contract completes when bound encounter clears.
- [x] Encounter first-time loot idempotency unchanged (E5.M3).
## Implementation reconciliation (shipped)
- **Types:** `ContractCompletionReasonCodes`, `ContractCompletionOperationResult`.
- **Orchestrator:** `ContractCompletionOperations.TryCompleteOnEncounterClear` — deliver-then-mark, outcome append, no-op on mismatch/no active.
- **Wiring:** `EncounterCompletionOperations` → contract hook after quest wiring; `EncounterCombatWiring` / `AbilityCastApi` pass contract DI.
- **Fixture:** `QuestFixtureRequest.resetContractInstanceIds` clears instance + delivery + outcome rows.
- **Tests:** 5 unit + 1 integration + 1 encounter wiring test; full suite **904** tests green.
- **Docs:** `server/README.md` contract completion section; E7.M4 module anchor.
## Technical approach
### 1. Types (`Game/Contracts/`)
| Type | Role |
|------|------|
| **`ContractCompletionReasonCodes`** | Stable codes: **`no_active_contract`**, **`encounter_mismatch`**, **`unknown_template`**, **`player_not_writable`**, **`invalid_ids`**; passthrough router codes on delivery deny (e.g. **`inventory_full`**, **`already_delivered`**) |
| **`ContractCompletionOperationResult`** | `success`, optional `reasonCode`, optional **`ContractInstanceState`** snapshot, optional **`ContractOutcomeRow`** |
### 2. `ContractCompletionOperations.TryCompleteOnEncounterClear`
**Inputs:** `playerId`, `encounterId`, injected `IContractInstanceStore`, `IContractOutcomeStore`, `IContractTemplateRegistry`, reward-router dependencies (same set as **`QuestStateOperations.TryMarkComplete`**), `TimeProvider`.
**Flow:**
1. Normalize **`playerId`** + **`encounterId`**; deny **`invalid_ids`** when empty.
2. **`instanceStore.TryGetActiveForPlayer`** — no active row ⇒ success no-op (**`no_active_contract`**, or null reason for silent no-op — prefer explicit reason in tests, null in wiring if best-effort silent).
3. Load template via **`templateRegistry.TryGetDefinition(active.TemplateId)`**; deny **`unknown_template`** when missing.
4. Normalize template **`encounterTemplateId`** vs cleared **`encounterId`** — mismatch ⇒ success no-op (**`encounter_mismatch`**).
5. If active snapshot status is already **`completed`** (defensive) ⇒ idempotent success.
6. **`RewardRouterOperations.TryDeliverContractCompletion`** with template **`CompletionRewardBundle`**.
7. On delivery deny ⇒ return deny with router **`reasonCode`**; instance stays **`active`**.
8. **`instanceStore.TryMarkComplete`** with **`timeProvider.GetUtcNow()`**; handle store replay idempotency (completed snapshot ⇒ success).
9. Build **`ContractOutcomeRow`** from delivery grants + **`ContractOutcomeIds.MakeIdempotencyKey`**; **`outcomeStore.TryAppend`** (best-effort if duplicate idempotency key after race — treat as success when instance already completed).
10. Comment-only **`contract_complete`** telemetry hook site (NEO-152).
**No-op vs deny:** Structural preconditions that mean “this encounter clear is not a contract objective” (**no active**, **encounter mismatch**) return **`success: true`** so encounter completion is never blocked. Delivery/store failures return **`success: false`**.
### 3. Encounter wiring
In **`EncounterCompletionOperations.TryCompleteAndGrant`**, after encounter event record + **`QuestObjectiveWiring.TryProcessEncounterCompleteSuccess`**, call:
```csharp
ContractCompletionOperations.TryCompleteOnEncounterClear(
normalizedPlayerId,
normalizedEncounterId,
/* contract + router deps */);
```
Extend **`EncounterCombatWiring.TryProcessCastOutcome`** and **`AbilityCastApi`** to resolve and pass:
- **`IContractInstanceStore`**
- **`IContractOutcomeStore`**
- **`IContractTemplateRegistry`**
(Mirror existing quest registry / progress store injection.)
### 4. Dev fixture reset (NEO-148 follow-on)
Extend **`QuestFixtureOperations`** reset body (or parallel contract fixture helper) to clear contract delivery rows via **`IRewardDeliveryStore.TryClear(playerId, RewardDeliverySourceKinds.ContractCompletion, contractInstanceId)`** when resetting contract test state — enables future Bruno loops in NEO-151.
### 5. Documentation
- **`server/README.md`**: new **Contract completion operations (NEO-149)** section — orchestrator flow, reason codes, deliver-then-mark, outcome append, encounter wiring order.
- Update **`E7_M4_ContractMissionGenerator.md`** Slice 4 anchor when shipped.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionReasonCodes.cs` | Stable deny / no-op reason codes |
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperationResult.cs` | Orchestrator result envelope |
| `server/NeonSprawl.Server/Game/Contracts/ContractCompletionOperations.cs` | **`TryCompleteOnEncounterClear`** orchestrator |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsTests.cs` | Unit tests — match/mismatch, deliver-then-mark, idempotent replay, delivery deny leaves active |
| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperationsIntegrationTests.cs` | DI path: issue → encounter complete → completed instance + delivery + outcome |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Call contract completion after quest wiring on successful encounter grant |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Pass contract store/registry deps to **`TryCompleteAndGrant`** |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Resolve contract DI services for combat wiring |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Update dependency resolution + assert encounter loot unchanged when contract completes |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs` | Pass contract deps; optional end-to-end issue → cast → contract completed |
| `server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs` | Contract delivery **`TryClear`** on fixture reset (NEO-148 deferred) |
| `server/README.md` | Contract completion operations + wiring order docs |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | NEO-149 completion wiring anchor when shipped |
## Tests
| File | Coverage |
|------|----------|
| `ContractCompletionOperationsTests.cs` | **`TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter`** — issue via generator helper, complete on **`prototype_combat_pocket`**, assert **`completed`**, delivery store row, outcome row, bundle grants |
| Same | **`TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract`** — encounter clear succeeds structurally; no delivery row |
| Same | **`TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch`** — active contract bound to combat pocket; clear different encounter id ⇒ still **`active`** |
| Same | **`TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies`** — full inventory / deny path; instance stays **`active`**, no outcome row |
| Same | **`TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed`** — second call after complete returns success without double grant |
| `ContractCompletionOperationsIntegrationTests.cs` | Full DI: **`TryIssue`** → **`TryCompleteAndGrant`** → stores consistent via **`InMemoryWebApplicationFactory`** |
| `EncounterCompletionOperationsTests.cs` | **`TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated`** still passes with contract deps wired; add case with active contract asserting **both** encounter loot (×10 scrap + token) **and** contract bundle (×5 scrap + salvage XP) |
| `EncounterCombatWiringTests.cs` | Update all **`TryProcessCastOutcome`** call sites with contract deps (compile fix) |
All new tests follow AAA with **`// Arrange` / `// Act` / `// Assert`** labels per [csharp-style](../.cursor/rules/csharp-style.md).
Manual Godot QA: **none** (server engine; capstone NEO-154). Bruno HTTP loop: **NEO-151**.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Contract issued after encounter already cleared | Prototype flow is issue-then-clear; edge case leaves contract **`active`** until player can re-trigger (not possible in v1) — document in README; no v1 fix | `adopted` |
| Outcome append fails after mark complete | Treat duplicate idempotency append as success when instance **`completed`**; log at debug if needed | `adopted` |
| Large signature growth on encounter completion | Accept for prototype — mirror quest deps already on **`TryCompleteAndGrant`**; refactor to context object only if a follow-on story demands | `deferred` |
| Bruno smoke in backlog vs no HTTP | Integration tests + fixture reset in this story; Bruno **`issue → clear → GET`** in **NEO-151** | `adopted` |
| **`contract_complete` telemetry | Comment-only hook in orchestrator; NEO-152 fills payload | `deferred` |
## Client counterpart
[NEO-153](https://linear.app/neon-sprawl/issue/NEO-153) — contract panel after E7M4-08 HTTP. [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154) — Godot capstone. This story is server-only; Bruno verification of the full player loop lands with NEO-151.

View File

@ -0,0 +1,60 @@
# Code review — NEO-149 contract completion encounter wiring
**Date:** 2026-06-27
**Scope:** Branch `NEO-149-e7m4-06-contract-completion-encounter-wiring` — commits `8d9d02f`..`eb6a4fe` vs `origin/main`
**Base:** `origin/main`
**Follow-up:** Re-review after code-review follow-ups (`68ba267``eb6a4fe`).
## Verdict
**Approve**
## Summary
NEO-149 adds `ContractCompletionOperations.TryCompleteOnEncounterClear` — deliver-then-mark contract completion on encounter clear, following the NEO-128 quest precedent and NEO-148 router. Encounter wiring invokes the hook after encounter loot and quest objective wiring; contract payout failure does not block encounter success. Contract store/registry dependencies are plumbed through `EncounterCombatWiring` and `AbilityCastApi`. `QuestFixtureRequest.resetContractInstanceIds` clears instance, delivery, and outcome rows for Bruno/dev reset loops.
**Follow-up commits** address all prior review suggestions: E7.M4 alignment register updated, `PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided` added, `TryMarkComplete` failure path returns `player_not_writable` / `instance_not_active` / `instance_not_found` instead of `invalid_ids`, and idempotent replay test refactored with `IssueAndCompleteOnce`. Ancillary analyzer fixes in encounter wiring (debug log guard) and craft API (`CraftResult` XML docs, `MapIoRows` return type, deny empty IO arrays test) keep the branch clean; craft changes are orthogonal to contract completion but low risk.
Seven contract-related tests (5 unit + 1 integration + 1 encounter wiring) plus 1 quest-fixture API test cover the story. Full suite: **906** tests passed locally.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-149-implementation-plan.md` | **Matches** — AC checklist complete; shipped reconciliation aligns with diff (test count still cites 904 — minor drift) |
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-06) | **Matches** — orchestrator + encounter wiring in scope; HTTP Bruno loop deferred to NEO-151 |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | **Matches** — NEO-149 completion wiring anchor added |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M4 row includes **E7M4-06 / NEO-149** |
| `server/README.md` | **Matches** — contract completion operations section, wiring order, reason codes, fixture reset |
| Client counterpart (NEO-153, NEO-154) | **N/A** — explicitly out of scope; no prototype-complete claim |
Register/tracking table updated for E7.M4 (NEO-149).
## Blocking issues
None.
## Suggestions
1. ~~**Update implementation alignment register** — Add **E7M4-06 / NEO-149 landed** to the E7.M4 row in `docs/decomposition/modules/documentation_and_implementation_alignment.md` (orchestrator + encounter wiring + quest-fixture reset), consistent with NEO-144NEO-148 entries.~~ **Done.** E7.M4 row + plan link updated (`68ba267`).
2. ~~**Quest-fixture test for contract reset** — `QuestFixtureOperations` now clears contract instance, delivery, and outcome rows via `resetContractInstanceIds`, but `QuestFixtureApiTests` has no parallel to `PostQuestFixture_ShouldClearRewardDeliveryAndAudit_WhenResetQuestIdsProvided`. A test that issues a contract, completes it, resets via fixture, and asserts stores are empty would lock the NEO-148 follow-on reset path.~~ **Done.** `PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided` (`68ba267`).
3. ~~**`TryMarkComplete` failure reason code** — When delivery succeeds but `TryMarkComplete` fails without leaving a completed snapshot, the orchestrator returns `invalid_ids`. `QuestStateOperations.TryMarkComplete` uses richer fallback codes. Consider aligning deny semantics.~~ **Done.** Final deny uses `player_not_writable`, `instance_not_active`, or `instance_not_found` (`68ba267`).
## Nits
- Nit: Idempotent replay after successful completion returns `no_active_contract` (instance no longer active) rather than hitting the defensive `completed` snapshot branch. Behavior is correct — no double grant — but the reason code differs from the plans “completed replay” wording. **Deferred** — acceptable for prototype.
- ~~Nit: `TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed` performs success assertions on the first completion inside **Arrange**; prefer moving first-call verification to **Assert** after **Act** for strict AAA, or extract a shared setup helper.~~ **Done.** `IssueAndCompleteOnce` setup helper (`68ba267`).
- Nit: Plan lists optional `EncounterCombatWiring` end-to-end issue → cast → contract completed; only compile-fix call-site updates landed. **Deferred** — integration + encounter completion tests cover the path.
- Nit: Bruno `Reset contract instance via quest fixture.bru` uses placeholder `ci_000…` id — fine until NEO-151 wires issue → clear → GET.
- Nit: Follow-up commits include craft API analyzer/doc fixes unrelated to E7M4-06 scope; harmless but could have been a separate chore commit.
## Verification
```bash
dotnet test NeonSprawl.sln
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~ContractCompletionOperations|FullyQualifiedName~QuestFixtureApiTests.PostQuestFixture_ShouldClearContractStores"
```
Manual Godot QA: none (server engine per plan). Bruno issue → clear → GET loop: NEO-151.

View File

@ -0,0 +1,171 @@
using Microsoft.Extensions.DependencyInjection;
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;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractCompletionOperationsIntegrationTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
private const string SeedBucket = "2026-06-22";
[Fact]
public async Task TryIssueThenEncounterComplete_ShouldMarkContractCompleted_WhenResolvedFromDi()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var deps = ResolveDependencies(factory);
var issued = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
Assert.True(issued.Success);
var instanceId = issued.Snapshot!.ContractInstanceId;
DefeatAllRequiredTargets(deps);
// Act
var encounterResult = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.InstanceStore,
deps.OutcomeStore,
deps.TemplateRegistry,
deps.TimeProvider);
// Assert
Assert.True(encounterResult.Success);
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance));
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(15, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
private static void DefeatAllRequiredTargets(IntegrationTestDependencies deps)
{
foreach (var npcId in new[] { MeleeNpc, RangedNpc, EliteNpc })
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
}
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static IntegrationTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
return new IntegrationTestDependencies(
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<TimeProvider>());
}
private sealed record IntegrationTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore,
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore EventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider);
}

View File

@ -0,0 +1,266 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractCompletionOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string MismatchEncounterId = "prototype_encounter_other";
private const string SeedBucket = "2026-06-22";
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(ContractInstanceStatus.Completed, result.Snapshot!.Status);
Assert.NotNull(result.Outcome);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out var delivery));
Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, delivery.SourceKind);
Assert.Equal(instanceId, delivery.ContractInstanceId);
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(
CountBagItem(beforeInventory!, "scrap_metal_bulk") + 5,
CountBagItem(afterInventory!, "scrap_metal_bulk"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
Assert.Equal(15, salvageXp);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var stored));
Assert.Equal(ContractInstanceStatus.Completed, stored.Status);
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(result.Success);
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.Null(result.Outcome);
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
// Act
var result = CompleteOnEncounterClear(deps, MismatchEncounterId);
// Assert
Assert.True(result.Success);
Assert.Equal(ContractCompletionReasonCodes.EncounterMismatch, result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot!.Status);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(instanceId, active.ContractInstanceId);
Assert.False(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
FillBag(deps);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(instanceId, active.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, active.Status);
Assert.Empty(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var instanceId = IssueAndCompleteOnce(deps);
// Act
var replay = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(replay.Success);
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, replay.ReasonCode);
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance));
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
}
private static string IssueAndCompleteOnce(CompletionTestDependencies deps)
{
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
var first = CompleteOnEncounterClear(deps, EncounterId);
Assert.True(first.Success);
Assert.Null(first.ReasonCode);
Assert.Equal(ContractInstanceStatus.Completed, first.Snapshot!.Status);
return instanceId;
}
private static ContractIssueOperationResult IssuePrototypeContract(CompletionTestDependencies deps) =>
ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
private static ContractCompletionOperationResult CompleteOnEncounterClear(
CompletionTestDependencies deps,
string encounterId) =>
ContractCompletionOperations.TryCompleteOnEncounterClear(
PlayerId,
encounterId,
deps.InstanceStore,
deps.OutcomeStore,
deps.TemplateRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static void FillBag(CompletionTestDependencies deps)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static CompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
return new CompletionTestDependencies(
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
new FakeTimeProvider(CompletedAt));
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private sealed record CompletionTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider);
}

View File

@ -266,6 +266,28 @@ public sealed class PlayerCraftApiTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public void MapResponse_ShouldReturnEmptyIoArrays_WhenCraftDenied()
{
// Arrange
var result = new CraftResult(
Success: false,
ReasonCode: CraftReasonCodes.UnknownRecipe,
InputsConsumed: [],
OutputsGranted: [],
XpGrantSummary: null);
// Act
var response = PlayerCraftApi.MapResponse(result);
// Assert
Assert.False(response.Success);
Assert.Equal(CraftReasonCodes.UnknownRecipe, response.ReasonCode);
Assert.Empty(response.InputsConsumed);
Assert.Empty(response.OutputsGranted);
Assert.Null(response.XpGrantSummary);
}
private static void SeedStack(InMemoryWebApplicationFactory factory, string itemId, int quantity)
{
var itemRegistry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
@ -49,6 +50,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -88,6 +92,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -131,6 +138,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -172,6 +182,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -206,6 +219,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
@ -232,6 +248,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -272,6 +291,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -321,6 +343,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -367,6 +392,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -397,6 +425,9 @@ public sealed class EncounterCombatWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
}
@ -455,7 +486,10 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -489,5 +523,8 @@ public sealed class EncounterCombatWiringTests
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry);
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
@ -19,6 +20,67 @@ public sealed class EncounterCompletionOperationsTests
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryCompleteAndGrant_ShouldApplyContractBundle_WhenActiveContractMatchesEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = ContractGeneratorOperations.TryIssue(
PlayerId,
"prototype_contract_clear_combat_pocket",
"2026-06-22",
zoneDifficultyBand: null,
deps.ContractTemplateRegistry,
deps.ContractInstanceStore,
deps.StandingStore,
TimeProvider.System);
Assert.True(issued.Success);
var instanceId = issued.Snapshot!.ContractInstanceId;
DefeatAllRequiredTargets(deps);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
Assert.True(result.Success);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(15, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
Assert.True(deps.ContractInstanceStore.TryGet(PlayerId, instanceId, out var instance));
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Single(deps.ContractOutcomeStore.GetOutcomesForInstance(instanceId));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
{
@ -49,6 +111,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
// Assert
@ -100,6 +165,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
@ -124,6 +192,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
timeProvider);
// Assert
@ -190,6 +261,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -232,6 +306,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -276,6 +353,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -318,6 +398,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -358,6 +441,9 @@ public sealed class EncounterCompletionOperationsTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
TimeProvider.System);
// Assert
@ -428,7 +514,10 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>());
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>());
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -476,7 +565,10 @@ public sealed class EncounterCompletionOperationsTests
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore);
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{

View File

@ -3,9 +3,13 @@ using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -271,4 +275,84 @@ public sealed class QuestFixtureApiTests
Assert.False(deliveryStore.TryGet(DevPlayer, ChainQuestId, out _));
Assert.Empty(auditStore.GetDeltasForPlayer(DevPlayer));
}
[Fact]
public async Task PostQuestFixture_ShouldClearContractStores_WhenResetContractInstanceIdsProvided()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var templateRegistry = factory.Services.GetRequiredService<IContractTemplateRegistry>();
var instanceStore = factory.Services.GetRequiredService<IContractInstanceStore>();
var outcomeStore = factory.Services.GetRequiredService<IContractOutcomeStore>();
var deliveryStore = factory.Services.GetRequiredService<IRewardDeliveryStore>();
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
var itemRegistry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var inventoryStore = factory.Services.GetRequiredService<IPlayerInventoryStore>();
var skillRegistry = factory.Services.GetRequiredService<ISkillDefinitionRegistry>();
var skillProgressionStore = factory.Services.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = factory.Services.GetRequiredService<ISkillLevelCurve>();
var perkUnlockEngine = factory.Services.GetRequiredService<PerkUnlockEngine>();
var perkStore = factory.Services.GetRequiredService<IPlayerPerkStateStore>();
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
var timeProvider = factory.Services.GetRequiredService<TimeProvider>();
const string templateId = "prototype_contract_clear_combat_pocket";
const string encounterId = "prototype_combat_pocket";
var issued = ContractGeneratorOperations.TryIssue(
DevPlayer,
templateId,
"2026-06-22",
zoneDifficultyBand: null,
templateRegistry,
instanceStore,
standingStore,
timeProvider);
Assert.True(issued.Success);
var instanceId = issued.Snapshot!.ContractInstanceId;
var completed = ContractCompletionOperations.TryCompleteOnEncounterClear(
DevPlayer,
encounterId,
instanceStore,
outcomeStore,
templateRegistry,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider);
Assert.True(completed.Success);
Assert.True(
deliveryStore.TryGet(
DevPlayer,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Single(outcomeStore.GetOutcomesForInstance(instanceId));
// Act
var reset = await client.PostAsJsonAsync(
FixturePath,
new QuestFixtureRequest
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
ResetContractInstanceIds = [instanceId],
});
// Assert
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.False(instanceStore.TryGet(DevPlayer, instanceId, out _));
Assert.False(
deliveryStore.TryGet(
DevPlayer,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
Assert.Empty(outcomeStore.GetOutcomesForInstance(instanceId));
}
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
@ -44,7 +45,7 @@ public sealed class QuestObjectiveWiringTests
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
Assert.Equal(
3 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather + 25,
(3 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather) + 25,
deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent));
Assert.Single(deliveryEvent.GrantedSkillXp);
@ -270,6 +271,9 @@ public sealed class QuestObjectiveWiringTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -459,6 +463,9 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>(),
TimeProvider.System);
}
@ -483,5 +490,8 @@ public sealed class QuestObjectiveWiringTests
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry,
TimeProvider TimeProvider);
}

View File

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
@ -412,6 +413,9 @@ public sealed class QuestProgressApiTests
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.ContractInstanceStore,
deps.ContractOutcomeStore,
deps.ContractTemplateRegistry,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -552,6 +556,9 @@ public sealed class QuestProgressApiTests
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IContractTemplateRegistry>(),
TimeProvider.System);
}
@ -576,5 +583,8 @@ public sealed class QuestProgressApiTests
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
IContractInstanceStore ContractInstanceStore,
IContractOutcomeStore ContractOutcomeStore,
IContractTemplateRegistry ContractTemplateRegistry,
TimeProvider TimeProvider);
}

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Gigs;
@ -93,6 +94,9 @@ public static class AbilityCastApi
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore,
IContractOutcomeStore contractOutcomeStore,
IContractTemplateRegistry contractTemplateRegistry,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
{
@ -302,6 +306,9 @@ public static class AbilityCastApi
deliveryStore,
standingStore,
auditStore,
contractInstanceStore,
contractOutcomeStore,
contractTemplateRegistry,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));

View File

@ -0,0 +1,11 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Server-internal contract completion resolution envelope (NEO-149).
/// HTTP DTOs via E7M4-08 (NEO-151).
/// </summary>
public readonly record struct ContractCompletionOperationResult(
bool Success,
string? ReasonCode,
ContractInstanceState? Snapshot,
ContractOutcomeRow? Outcome);

View File

@ -0,0 +1,210 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Server-authoritative contract completion on encounter clear (NEO-149).
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152).
/// </summary>
public static class ContractCompletionOperations
{
/// <summary>
/// When the player has an active contract bound to <paramref name="encounterId"/>, delivers the template
/// completion bundle then marks the instance complete and appends an outcome audit row.
/// </summary>
public static ContractCompletionOperationResult TryCompleteOnEncounterClear(
string playerId,
string encounterId,
IContractInstanceStore instanceStore,
IContractOutcomeStore outcomeStore,
IContractTemplateRegistry templateRegistry,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId);
var normalizedEncounterId = NormalizeEncounterId(encounterId);
if (normalizedPlayerId.Length == 0 || normalizedEncounterId.Length == 0)
{
return Deny(ContractCompletionReasonCodes.InvalidIds);
}
if (!instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot))
{
return NoOp(ContractCompletionReasonCodes.NoActiveContract);
}
if (!templateRegistry.TryGetDefinition(activeSnapshot.TemplateId, out var template))
{
return Deny(ContractCompletionReasonCodes.UnknownTemplate, activeSnapshot);
}
if (!EncounterMatchesTemplate(normalizedEncounterId, template.EncounterTemplateId))
{
return NoOp(ContractCompletionReasonCodes.EncounterMismatch, activeSnapshot);
}
if (activeSnapshot.Status == ContractInstanceStatus.Completed)
{
return Success(activeSnapshot, TryGetExistingOutcome(outcomeStore, activeSnapshot));
}
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
{
return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot);
}
var delivery = RewardRouterOperations.TryDeliverContractCompletion(
normalizedPlayerId,
activeSnapshot.ContractInstanceId,
template.CompletionRewardBundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider);
if (!delivery.Success)
{
return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidContractInstanceId, activeSnapshot);
}
var completedAt = timeProvider.GetUtcNow();
if (instanceStore.TryMarkComplete(
normalizedPlayerId,
activeSnapshot.ContractInstanceId,
completedAt,
out var completedSnapshot))
{
var outcome = AppendOutcome(
outcomeStore,
completedSnapshot,
delivery,
completedAt);
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_complete` ---
// TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns below without hook.
// Planned payload: playerId, contractInstanceId, templateId, encounterId, completedAt, idempotencyKey.
return Success(completedSnapshot, outcome);
}
if (completedSnapshot?.Status == ContractInstanceStatus.Completed)
{
return Success(completedSnapshot, TryGetExistingOutcome(outcomeStore, completedSnapshot));
}
if (instanceStore.TryGet(
normalizedPlayerId,
activeSnapshot.ContractInstanceId,
out var afterAttempt) &&
afterAttempt.Status == ContractInstanceStatus.Completed)
{
return Success(afterAttempt, TryGetExistingOutcome(outcomeStore, afterAttempt));
}
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
{
return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot);
}
if (instanceStore.TryGet(
normalizedPlayerId,
activeSnapshot.ContractInstanceId,
out var currentAfterFailedMark))
{
return Deny(ContractInstanceReasonCodes.InstanceNotActive, currentAfterFailedMark);
}
return Deny(ContractInstanceReasonCodes.InstanceNotFound, activeSnapshot);
}
private static ContractOutcomeRow? AppendOutcome(
IContractOutcomeStore outcomeStore,
ContractInstanceState snapshot,
RewardDeliveryResult delivery,
DateTimeOffset recordedAt)
{
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
snapshot.PlayerId,
snapshot.ContractInstanceId);
if (idempotencyKey.Length == 0)
{
return null;
}
var row = new ContractOutcomeRow(
MakeOutcomeRowId(idempotencyKey),
snapshot.ContractInstanceId,
snapshot.PlayerId,
idempotencyKey,
delivery.GrantedItems,
delivery.GrantedSkillXp,
delivery.GrantedReputation,
recordedAt);
if (outcomeStore.TryAppend(row))
{
return row;
}
return outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
? existing
: null;
}
private static ContractOutcomeRow? TryGetExistingOutcome(
IContractOutcomeStore outcomeStore,
ContractInstanceState snapshot)
{
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
snapshot.PlayerId,
snapshot.ContractInstanceId);
return idempotencyKey.Length > 0 &&
outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
? existing
: null;
}
private static string MakeOutcomeRowId(string idempotencyKey) => $"{idempotencyKey}:outcome";
private static bool EncounterMatchesTemplate(string normalizedEncounterId, string templateEncounterId) =>
string.Equals(
normalizedEncounterId,
NormalizeEncounterId(templateEncounterId),
StringComparison.Ordinal);
private static string NormalizeEncounterId(string? encounterId) =>
encounterId?.Trim() ?? string.Empty;
private static ContractCompletionOperationResult NoOp(
string reasonCode,
ContractInstanceState? snapshot = null) =>
new(true, reasonCode, snapshot, null);
private static ContractCompletionOperationResult Success(
ContractInstanceState snapshot,
ContractOutcomeRow? outcome) =>
new(true, null, snapshot, outcome);
private static ContractCompletionOperationResult Deny(
string reasonCode,
ContractInstanceState? snapshot = null) =>
new(false, reasonCode, snapshot, null);
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Stable reason codes for <see cref="ContractCompletionOperations.TryCompleteOnEncounterClear"/> (NEO-149).</summary>
public static class ContractCompletionReasonCodes
{
public const string InvalidIds = ContractInstanceReasonCodes.InvalidIds;
public const string PlayerNotWritable = ContractInstanceReasonCodes.PlayerNotWritable;
public const string NoActiveContract = "no_active_contract";
public const string EncounterMismatch = "encounter_mismatch";
public const string UnknownTemplate = "unknown_template";
}

View File

@ -4,12 +4,12 @@ namespace NeonSprawl.Server.Game.Crafting;
/// Server-internal craft resolution envelope (NEO-69); promoted to wire JSON via <see cref="CraftResponse"/> (NEO-70).
/// NEO-71 telemetry hook sites live in <see cref="CraftOperations.TryCraft"/> (success path + centralized <c>Deny</c> helper).
/// </summary>
/// <param name="InputsConsumed">Scaled inputs removed on success; empty when denied.</param>
/// <param name="OutputsGranted">Scaled outputs added on success; empty when denied.</param>
/// <param name="XpGrantSummary">Refine skill XP applied on success; null when denied.</param>
public readonly record struct CraftResult(
bool Success,
string? ReasonCode,
/// <summary>Scaled inputs removed on success; empty when denied.</summary>
IReadOnlyList<CraftIoApplied> InputsConsumed,
/// <summary>Scaled outputs added on success; empty when denied.</summary>
IReadOnlyList<CraftIoApplied> OutputsGranted,
/// <summary>Refine skill XP applied on success; null when denied.</summary>
CraftXpGrantSummary? XpGrantSummary);

View File

@ -96,11 +96,11 @@ public static class PlayerCraftApi
};
}
private static IReadOnlyList<CraftIoAppliedJson> MapIoRows(IReadOnlyList<CraftIoApplied> rows)
private static CraftIoAppliedJson[] MapIoRows(IReadOnlyList<CraftIoApplied> rows)
{
if (rows.Count == 0)
{
return Array.Empty<CraftIoAppliedJson>();
return [];
}
var mapped = new CraftIoAppliedJson[rows.Count];

View File

@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -38,6 +40,9 @@ public static class EncounterCombatWiring
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore,
IContractOutcomeStore contractOutcomeStore,
IContractTemplateRegistry contractTemplateRegistry,
TimeProvider timeProvider,
ILogger? logger = null)
{
@ -104,15 +109,22 @@ public static class EncounterCombatWiring
deliveryStore,
standingStore,
auditStore,
contractInstanceStore,
contractOutcomeStore,
contractTemplateRegistry,
timeProvider);
if (!completion.Success)
{
logger?.LogDebug(
"Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})",
playerId,
encounterId,
completion.ReasonCode);
if (logger?.IsEnabled(LogLevel.Debug) is true)
{
logger.LogDebug(
"Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})",
playerId,
encounterId,
completion.ReasonCode);
}
return;
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -41,6 +42,9 @@ public static class EncounterCompletionOperations
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore,
IContractOutcomeStore contractOutcomeStore,
IContractTemplateRegistry contractTemplateRegistry,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
@ -167,6 +171,24 @@ public static class EncounterCompletionOperations
auditStore,
timeProvider);
_ = ContractCompletionOperations.TryCompleteOnEncounterClear(
normalizedPlayerId,
normalizedEncounterId,
contractInstanceStore,
contractOutcomeStore,
contractTemplateRegistry,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider);
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,
@ -190,7 +212,7 @@ public static class EncounterCompletionOperations
private static void CompensatingRemoveGrants(
string playerId,
IReadOnlyList<EncounterGrantApplied> grants,
List<EncounterGrantApplied> grants,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore)
{

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
@ -15,6 +16,7 @@ public static class QuestFixtureApi
IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore,
IContractInstanceStore contractInstanceStore, IContractOutcomeStore contractOutcomeStore,
TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
@ -37,6 +39,8 @@ public static class QuestFixtureApi
standingStore,
deliveryStore,
auditStore,
contractInstanceStore,
contractOutcomeStore,
timeProvider))
{
return Results.NotFound();

View File

@ -21,6 +21,10 @@ public sealed class QuestFixtureRequest
/// <summary>Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0).</summary>
[JsonPropertyName("resetFactionIds")]
public IReadOnlyList<string> ResetFactionIds { get; init; } = [];
/// <summary>Contract instance ids to clear from instance, delivery, and outcome stores (dev Bruno reset).</summary>
[JsonPropertyName("resetContractInstanceIds")]
public IReadOnlyList<string> ResetContractInstanceIds { get; init; } = [];
}
/// <summary>POST response for quest fixture apply.</summary>

View File

@ -1,5 +1,6 @@
namespace NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
@ -19,11 +20,14 @@ public static class QuestFixtureOperations
IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore,
IReputationDeltaStore auditStore,
IContractInstanceStore instanceStore,
IContractOutcomeStore outcomeStore,
TimeProvider timeProvider)
{
if (body.CompletedQuestIds.Count == 0 &&
body.ResetQuestIds.Count == 0 &&
body.ResetFactionIds.Count == 0)
body.ResetFactionIds.Count == 0 &&
body.ResetContractInstanceIds.Count == 0)
{
return true;
}
@ -69,6 +73,33 @@ public static class QuestFixtureOperations
}
}
foreach (var contractInstanceId in body.ResetContractInstanceIds)
{
var normalizedInstanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (normalizedInstanceId.Length == 0)
{
return false;
}
if (!deliveryStore.TryClear(
playerId,
RewardDeliverySourceKinds.ContractCompletion,
normalizedInstanceId))
{
return false;
}
if (!instanceStore.TryClearInstance(playerId, normalizedInstanceId))
{
return false;
}
if (!outcomeStore.TryClearForInstance(normalizedInstanceId))
{
return false;
}
}
foreach (var questId in body.CompletedQuestIds)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))

View File

@ -314,6 +314,37 @@ Completed instances are immutable (no status regression). Player, template, and
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)
**`ContractCompletionOperations.TryCompleteOnEncounterClear`** completes an **`active`** contract when the cleared encounter matches the template **`encounterTemplateId`**. Call this orchestrator (or rely on encounter wiring) — not stores or the reward router directly.
**Wiring order:** **`EncounterCompletionOperations.TryCompleteAndGrant`** applies first-time encounter loot (E5.M3), records **`EncounterCompleteEvent`**, runs **`QuestObjectiveWiring`**, then invokes contract completion. Encounter success is never blocked by contract payout failure.
**Flow:**
1. **`TryGetActiveForPlayer`** — no active row ⇒ success no-op (**`no_active_contract`**).
2. Load template; deny **`unknown_template`** when missing.
3. Encounter id mismatch ⇒ success no-op (**`encounter_mismatch`**).
4. **`RewardRouterOperations.TryDeliverContractCompletion`** (deliver-then-mark).
5. On delivery deny, instance stays **`active`**; router **`reasonCode`** passthrough (e.g. **`inventory_full`**).
6. **`IContractInstanceStore.TryMarkComplete`** then **`IContractOutcomeStore.TryAppend`** with grant snapshots from the delivery result.
**Reason codes:**
| Code | When |
|------|------|
| **`no_active_contract`** | Success no-op — player has no active instance |
| **`encounter_mismatch`** | Success no-op — cleared encounter is not the template objective |
| **`unknown_template`** | Active instance references a missing template id |
| **`invalid_ids`**, **`player_not_writable`** | Normalization / write gate failures |
| *(router passthrough)* | Delivery deny — e.g. **`inventory_full`**, **`already_delivered`** |
**Dev fixture reset:** **`POST …/__dev/quest-fixture`** accepts optional **`resetContractInstanceIds`** — clears contract instance, **`contract_completion`** delivery row, and outcome audit for each id (NEO-149).
**Prototype edge case:** issuing a contract after the bound encounter is already cleared leaves the instance **`active`** with no re-trigger in v1 — prototype flow is issue-then-clear.
HTTP contract GET/issue projections land in **NEO-151** (E7M4-08). Plan: [NEO-149 implementation plan](../../docs/plans/NEO-149-implementation-plan.md).
## Contract outcome store (NEO-146)
Append-only contract completion audit rows live in **`IContractOutcomeStore`**. Each **`ContractOutcomeRow`** captures instance id, delivery idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, grant snapshot summary (items, skill XP, reputation — same shapes as **`RewardDeliveryEvent`**), and **`recordedAt`**.
@ -491,7 +522,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest and
| **`progression_store_missing`** | Skill progression store could not apply XP delta. |
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations`** will call **`TryDeliverContractCompletion`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md).
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations.TryCompleteOnEncounterClear`** — invoked from **`EncounterCompletionOperations`** after encounter loot + quest wiring; deliver-then-mark via **`TryDeliverContractCompletion`**, outcome append via **`IContractOutcomeStore`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md), [NEO-149](../../docs/plans/NEO-149-implementation-plan.md).
### Reward telemetry hooks (NEO-130)