Merge pull request #144 from ViPro-Technologies/NEO-105-encounter-completion-inventory-reward-apply

NEO-105: EncounterCompletionOperations + inventory reward apply
pull/145/head
VinPropane 2026-05-31 17:19:37 -04:00 committed by GitHub
commit 7656fded03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 875 additions and 14 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M3 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
| **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
## Purpose
@ -88,6 +88,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
**Progress + completion stores landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** (in-memory) + **`EncounterProgressOperations`** — activation, order-independent defeat tracking, idempotent completion flag; combat/HTTP wiring deferred to NEO-106/108. Plan: [NEO-104 implementation plan](../../plans/NEO-104-implementation-plan.md); [server README — Encounter progress](../../../server/README.md#encounter-progress--completion-stores-neo-104).
**Completion + inventory grants landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations.TryCompleteAndGrant`** + **`EncounterCompleteEvent`** result payload — reward-table fixed grants via **`PlayerInventoryOperations`**, fail-closed inventory deny, idempotent completion mark; event persistence deferred to NEO-107. Plan: [NEO-105 implementation plan](../../plans/NEO-105-implementation-plan.md); [server README — Encounter completion](../../../server/README.md#encounter-completion--inventory-grants-neo-105).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.

File diff suppressed because one or more lines are too long

View File

@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **Grants + combat/HTTP wire (NEO-105+) outstanding.**
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **Combat/HTTP wire (NEO-106+) outstanding.**
### Epic 6 — PvP Security

View File

@ -211,9 +211,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria**
- [ ] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically.
- [ ] Second completion does not duplicate grants.
- [ ] Inventory deny fails closed without marking complete.
- [x] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically.
- [x] Second completion does not duplicate grants.
- [x] Inventory deny fails closed without marking complete.
**Landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** `EncounterCompletionOperations.TryCompleteAndGrant` + `EncounterCompleteEvent` result payload; AAA unit tests; plan [NEO-105-implementation-plan.md](NEO-105-implementation-plan.md).
**Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels.

View File

@ -0,0 +1,131 @@
# NEO-105 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-105 |
| **Title** | E5M3-06: EncounterCompletionOperations + inventory reward apply |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-105/e5m3-06-encountercompletionoperations-inventory-reward-apply |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-06** |
| **Branch** | `NEO-105-encounter-completion-inventory-reward-apply` |
| **Precursor** | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) — `IEncounterProgressStore` + `IEncounterCompletionStore` + `EncounterProgressOperations` (**landed on `main`**) |
| **Pattern** | [NEO-62](NEO-62-implementation-plan.md) / `GatherOperations` — static ops + reason codes + grant summary; [NEO-69](NEO-69-implementation-plan.md) / `CraftOperations` — simulate-all pre-flight + compensating rollback on partial apply failure |
| **Blocks** | [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) — combat defeat → progress/completion wiring; [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) — `EncounterCompleteEvent` persistence + E7.M2 hook stub |
| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels (via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) HTTP read) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **EncounterCompleteEvent scope** | Define event type on NEO-105 vs defer entirely to NEO-107? | **Define `EncounterCompleteEvent` + return in `EncounterCompletionResult` on success** — E5M3-06 goal calls for internal completion payload; **NEO-107** persists the record and adds E7.M2 hook stub (E5M3-09). | **Adopted** — result payload only; no persistence |
| **Grant transaction** | Pre-flight vs apply-then-hope? | **Simulate all `fixedGrants` on inventory snapshot, then apply sequentially with compensating rollback** — mirror `CraftOperations`; never `TryMarkCompleted` unless every grant applied. | **Adopted** (repo precedent; Linear AC “inventory deny fails closed”) |
| **Completion gate** | Who checks all-defeated? | **`TryCompleteAndGrant` requires `EncounterProgressOperations.IsAllRequiredTargetsDefeated`** + not already completed — NEO-106 calls after last defeat mark. | **Adopted** (backlog + NEO-104 split) |
| **Combat / HTTP wiring** | Wire `AbilityCastApi` or HTTP on this story? | **Defer** — NEO-106 / NEO-108; NEO-105 ships operations + unit tests only. | **Adopted** (backlog out-of-scope) |
| **Bruno** | Add encounter-completion Bruno folder? | **Defer to NEO-108** — no player HTTP route yet; backlog marks Bruno optional here. | **Adopted** (backlog) |
## Goal, scope, and out-of-scope
**Goal:** `EncounterCompletionOperations.TryCompleteAndGrant` applies **`RewardTable.fixedGrants`** via **`PlayerInventoryOperations`** exactly once per player+encounter, marks the completion store only when all grants succeed, and returns an internal **`EncounterCompleteEvent`** payload for downstream routers (persistence deferred).
**In scope (from Linear + [E5M3-06](E5M3-prototype-backlog.md#e5m3-06--encountercompletionoperations--inventory-reward-apply)):**
- **`EncounterCompletionOperations.TryCompleteAndGrant`** — resolve encounter → reward table → pre-flight simulate → apply fixed grants → `TryMarkCompleted` with `TimeProvider.GetUtcNow()`.
- **`EncounterCompletionResult`**, **`EncounterCompletionReasonCodes`**, **`EncounterGrantApplied`**, **`EncounterCompleteEvent`** (internal record; not persisted).
- Structured deny codes: `already_completed`, `not_ready`, `unknown_encounter`, `unknown_reward_table`, `inventory_full`, `inventory_store_missing`, passthrough `invalid_item` / `invalid_quantity`.
- Compensating inventory rollback when a later grant fails after earlier grants applied, or when `TryMarkCompleted` returns false after grants (race/idempotent replay).
- Unit tests (AAA) with `InMemoryWebApplicationFactory`: first completion grants frozen prototype loot once; second completion no duplicate; bag-full deny without completion flag.
- `server/README.md` encounter completion section (brief).
**Out of scope (from Linear + backlog):**
- Combat defeat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)).
- **`EncounterCompleteEvent` store / E7.M2 quest-credit hook** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)).
- HTTP **`GET /game/players/{id}/encounter-progress`** ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
- Godot HUD — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**.
- Postgres persistence, Bruno, `docs/manual-qa/NEO-105.md`.
## Acceptance criteria checklist
- [x] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically.
- [x] Second completion does not duplicate grants.
- [x] Inventory deny fails closed without marking complete.
## Implementation reconciliation (shipped)
- **Operations:** `EncounterCompletionOperations.TryCompleteAndGrant` — pre-flight simulate, sequential grant apply with compensating rollback, idempotent `TryMarkCompleted`.
- **Types:** `EncounterCompletionResult`, `EncounterCompletionReasonCodes`, `EncounterGrantApplied`, `EncounterCompleteEvent` (result payload only; NEO-107 persists).
- **Tests:** `EncounterCompletionOperationsTests` — grant once, `already_completed` replay, `not_ready`, `inventory_full` fail-closed, apply-phase rollback (`InventoryStoreFullAfterFirstGrant`), mark-fail rollback.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
## Technical approach
1. **`EncounterCompletionReasonCodes`**
- Domain-specific: `already_completed`, `not_ready`, `unknown_encounter`, `unknown_reward_table`, `inventory_store_missing`.
- Passthrough: `inventory_full`, `invalid_item`, `invalid_quantity` from **`PlayerInventoryReasonCodes`**.
2. **`EncounterGrantApplied`** — readonly record `(ItemId, Quantity)` mirroring **`GatherGrantApplied`**.
3. **`EncounterCompleteEvent`** — readonly record: `PlayerId`, `EncounterId`, `RewardTableId`, `IReadOnlyList<EncounterGrantApplied> GrantedItems`, `DateTimeOffset CompletedAt`, `IdempotencyKey` (stable string e.g. `{playerId}:{encounterId}` normalized). Returned on success only; **NEO-107** will persist/emit.
4. **`EncounterCompletionResult`** — `(Success, ReasonCode?, GrantsApplied, EncounterCompleteEvent? CompleteEvent, DateTimeOffset? CompletedAt)`.
5. **`EncounterCompletionOperations.TryCompleteAndGrant`**
- Normalize **`encounterId`** via **`IEncounterDefinitionRegistry.TryNormalizeKnown`**; load **`EncounterDefRow`**.
- Early deny **`already_completed`** if **`IEncounterCompletionStore.IsCompleted`**.
- Early deny **`not_ready`** if **`!EncounterProgressOperations.IsAllRequiredTargetsDefeated`**.
- Resolve **`rewardTableId`** → **`IRewardTableDefinitionRegistry.TryGetDefinition`**; deny **`unknown_reward_table`** if missing.
- Load **`FixedGrants`** (prototype: scrap ×10, token ×1).
- **Pre-flight:** clone inventory snapshot; **`PlayerInventoryOperations.TrySimulateAddStack`** for each grant in catalog order.
- **Apply:** foreach grant **`TryAddStack`**; on deny/store-missing, **`CompensatingRemoveGrants`** for prior applied rows and return deny **without** marking complete.
- **`TryMarkCompleted(playerId, encounterId, timeProvider.GetUtcNow())`**; if `false`, compensating remove all grants and return **`already_completed`**.
- Success: build **`EncounterCompleteEvent`**, return grants + event + `CompletedAt`.
- Comment-only hook site: `// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent` (NEO-107 expands).
6. **No new DI registration** — static operations like **`GatherOperations`**; callers inject stores/registries from existing **`AddEncounterAndRewardCatalogs`** + inventory DI.
### Prototype completion flow (frozen)
| Step | Expected |
|------|----------|
| Setup | Activate + defeat all three NPCs → `IsAllRequiredTargetsDefeated` true |
| First grant | `TryCompleteAndGrant` → success; bag +10 scrap, +1 token; completion store marked |
| Replay | Second `TryCompleteAndGrant` → deny `already_completed`; inventory unchanged |
| Bag full | Fill bag slots → `TryCompleteAndGrant` → deny `inventory_full`; `IsCompleted` false |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs` | Stable deny reason constants. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs` | Successful grant row in result/event. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs` | Internal completion payload (returned, not persisted on NEO-105). |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs` | Server-internal completion resolution envelope. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | `TryCompleteAndGrant` orchestration. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | AAA unit tests for grant-once, idempotent deny, inventory-full fail-closed. |
| `docs/plans/NEO-105-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/README.md` | Document `EncounterCompletionOperations`, reason codes, and deferrals to NEO-106/107/108. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — completion + inventory grant ops (NEO-105). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-105 completion grants when landed. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-105 completion ops landed (when shipped). |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-06 AC when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `EncounterCompletionOperationsTests.cs` | **Arrange:** `InMemoryWebApplicationFactory` + activate/defeat-all helper for `prototype_combat_pocket`. **Act/Assert:** first `TryCompleteAndGrant` success with scrap ×10 + token ×1 in inventory and `CompleteEvent` populated; second call `already_completed` with unchanged quantities; `not_ready` when only 2/3 defeated; bag-full deny `inventory_full` with `IsCompleted` false and zero grant delta; apply-phase rollback via `InventoryStoreFullAfterFirstGrant` when second grant fails after first succeeds; mark-fail rollback when `TryMarkCompleted` returns false. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Grant order** | Apply in **`FixedGrants` catalog order** (scrap then token); rollback in reverse on failure. | **adopted** |
| **Mark-complete race** | If `TryMarkCompleted` false after grants, remove grants and return `already_completed` — treat as lost race to another completer. | **adopted** |
| **Event persistence** | Deferred to **NEO-107** — NEO-105 only returns payload in result. | **adopted** |
| **Postgres follow-on** | Defer — completion store Postgres migration is a future session-persistence issue. | **deferred** |

View File

@ -0,0 +1,60 @@
# Code review — NEO-105 (E5M3-06)
**Date:** 2026-05-31
**Scope:** Branch `NEO-105-encounter-completion-inventory-reward-apply` vs `719a14f` (merge-base on `main`) — commits `b5306aa``009fedf`
**Base:** `719a14f` (main at branch point, post NEO-104 merge)
**Follow-up:** Review suggestions and nits addressed in commit after `009fedf`.
## Verdict
**Approve with nits**
## Summary
NEO-105 adds **`EncounterCompletionOperations.TryCompleteAndGrant`**, which resolves an encounter and reward table, pre-flights inventory via **`PlayerInventoryOperations.TrySimulateAddStack`**, applies **`fixedGrants`** sequentially with compensating rollback on partial failure or failed **`TryMarkCompleted`**, and returns an internal **`EncounterCompleteEvent`** payload on success (persistence deferred to NEO-107). Supporting types mirror established patterns (`GatherOperations` / `CraftOperations`): **`EncounterCompletionResult`**, **`EncounterCompletionReasonCodes`**, **`EncounterGrantApplied`**. Six AAA unit tests cover grant-once, idempotent replay, `not_ready`, bag-full fail-closed, mark-fail rollback, and unknown encounter. Docs (plan, backlog, E5.M3 snapshot, alignment register, module register, `server/README.md`) are updated. Combat wiring and HTTP read remain correctly deferred to NEO-106 / NEO-108; client counterpart NEO-110 is out of scope. Risk is low: static ops with no new DI or player-visible surface until NEO-106.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-105-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; combat/HTTP/client deferred. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-06) | **Matches** — acceptance criteria checked; landed note cites plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Status marks **E5M3-06 / NEO-105** landed; implementation snapshot bullet added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-105** completion grants; NEO-106+ combat/HTTP still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-105** completion ops landed. |
| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110** via NEO-108; no prototype slice completion claim. |
| `server/README.md` | **Matches** — encounter completion section documents ops, reason codes, frozen grants, and deferrals. |
## Blocking issues
None.
## Suggestions
1. ~~**Apply-phase partial rollback test** — The plan test table mentions compensating rollback when the bag cannot fit both grants, but **`FillBag`** fills every slot so pre-flight denies before any apply; the mark-fail test covers rollback after both grants succeed. Consider an AAA test where pre-flight passes but the second **`TryAddStack`** fails during apply (e.g. stub inventory store that allows simulate/add for the first grant only) to exercise **`CompensatingRemoveGrants`** on the apply path, not only the mark-complete race path.~~ **Done.** `TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails` uses **`InventoryStoreFullAfterFirstGrant`** to fill the bag after the first apply so pre-flight still passes.
2. ~~**Denial telemetry hook comment** — **`EncounterProgressOperations`** documents an E9.M1 hook on activation; **`CraftOperations`** / **`GatherOperations`** comment deny sites. **`EncounterCompletionOperations`** only has the E7.M2 success TODO. A brief `// TODO(E9.M1): catalog emit — encounter_complete_denied` (or similar) on **`Deny`** would align with NEO-109 without adding runtime code.~~ **Done.** `encounter_complete_denied` hook comment added on **`Deny`**.
3. ~~**`unknown_reward_table` for empty `FixedGrants`** — Empty grants return **`unknown_reward_table`** (also documented in README). Prototype catalog gates prevent this at startup; if a future table legitimately has zero fixed grants, a dedicated reason code may be clearer. Fine for prototype; note for catalog expansion.~~ **Addressed.** Inline comment on empty-grants branch; dedicated reason deferred until zero-grant tables ship.
## Nits
- ~~Nit: **`MapInventoryReason`** default branch maps unrecognized inventory reason codes to **`inventory_full`**. Safe given startup validation; passthrough of the raw code (or a dedicated **`inventory_denied`**) would aid debugging if invalid catalog rows slip through in tests.~~ **Done.** Unrecognized codes passthrough; **`null`** still maps to **`inventory_full`**.
- ~~Nit: **`TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice`** — first successful completion is correctly in **Arrange**; consider asserting **`first.ReasonCode`** is null and **`second.GrantsApplied`** is empty in one block for symmetry with the success test (already partially covered).~~ **Done.** Assert **`first.ReasonCode`** null and **`first.GrantsApplied.Count`** == 2 alongside second-call denies.
## Verification
```bash
# NEO-105-focused (6 tests — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterCompletionOperations"
# Broader encounter slice (62 tests — passed locally; one flaky failure observed on first full run, passed on retry)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
# CI parity
python3 scripts/validate_content.py
```
**Local run:** 6/6 `EncounterCompletionOperations` tests passed; 62/62 `Encounters` filter tests passed on retry.

View File

@ -0,0 +1,420 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class EncounterCompletionOperationsTests
{
private const string PlayerId = "dev-local-1";
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";
[Fact]
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(2, result.GrantsApplied.Count);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "scrap_metal_bulk" && g.Quantity == 10);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "contract_handoff_token" && g.Quantity == 1);
Assert.NotNull(result.CompleteEvent);
Assert.Equal(PlayerId, result.CompleteEvent!.Value.PlayerId);
Assert.Equal(EncounterId, result.CompleteEvent.Value.EncounterId);
Assert.Equal("prototype_combat_pocket_clear", result.CompleteEvent.Value.RewardTableId);
Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey);
Assert.NotNull(result.CompletedAt);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
var first = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
// Act
var second = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterSecond);
Assert.True(first.Success);
Assert.Null(first.ReasonCode);
Assert.Equal(2, first.GrantsApplied.Count);
Assert.False(second.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, second.ReasonCode);
Assert.Empty(second.GrantsApplied);
Assert.Null(second.CompleteEvent);
Assert.Equal(10, CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyNotReady_WhenOnlyTwoTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyInventoryFull_WhenBagHasNoFreeSlots()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
FillBag(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldRollbackGrants_WhenCompletionMarkFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var failingCompletionStore = new CompletionStoreDeniesMark(deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
failingCompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, result.ReasonCode);
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var inventoryStore = new InventoryStoreFullAfterFirstGrant(deps.InventoryStore, deps.ItemRegistry);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
inventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyUnknownEncounter_WhenIdIsInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
"unknown_encounter",
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode);
}
private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps)
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
EliteNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
}
private static void FillBag(EncounterCompletionTestDependencies 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 EncounterCompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new EncounterCompletionTestDependencies(
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
}
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 int TotalBagQuantity(PlayerInventorySnapshot snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
total += slot.Quantity;
}
}
return total;
}
private sealed record EncounterCompletionTestDependencies(
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{
public bool IsCompleted(string playerId, string encounterId) =>
inner.IsCompleted(playerId, encounterId);
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => false;
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) =>
inner.TryGetCompletedAt(playerId, encounterId, out completedAt);
}
/// <summary>
/// After the first successful inventory mutation, fills remaining bag slots so the next apply fails
/// while pre-flight simulate (on an empty snapshot) still passes.
/// </summary>
private sealed class InventoryStoreFullAfterFirstGrant(
IPlayerInventoryStore inner,
IItemDefinitionRegistry itemRegistry) : IPlayerInventoryStore
{
private bool _firstMutationApplied;
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot) =>
inner.TryGetSnapshot(playerId, out snapshot);
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) =>
inner.TryReplaceSnapshot(playerId, snapshot);
public bool TryMutateSnapshot(
string playerId,
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
out PlayerInventorySnapshot result)
{
if (!inner.TryMutateSnapshot(playerId, mutator, out result))
{
return false;
}
if (!_firstMutationApplied)
{
_firstMutationApplied = true;
FillRemainingBagSlots(playerId);
inner.TryGetSnapshot(playerId, out result);
}
return true;
}
private void FillRemainingBagSlots(string playerId)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
playerId,
"survey_drone_kit",
quantity: 1,
itemRegistry,
inner);
if (add.Kind != PlayerInventoryMutationKind.Applied)
{
break;
}
}
}
}
}

View File

@ -56,7 +56,7 @@ public static class CraftOperations
}
}
var simulated = CloneSnapshot(snapshot);
var simulated = snapshot.Clone();
foreach (var input in scaledInputs)
{
if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry))
@ -176,13 +176,6 @@ public static class CraftOperations
return scaled;
}
private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) =>
new()
{
BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots),
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots),
};
private static void CompensatingRestoreInputs(
string playerId,
IReadOnlyList<CraftIoApplied> inputs,

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Internal completion payload returned on successful encounter grant (NEO-105).
/// Persistence and E7.M2 quest-credit routing land in NEO-107.
/// </summary>
public readonly record struct EncounterCompleteEvent(
string PlayerId,
string EncounterId,
string RewardTableId,
IReadOnlyList<EncounterGrantApplied> GrantedItems,
DateTimeOffset CompletedAt,
string IdempotencyKey);

View File

@ -0,0 +1,173 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Applies encounter reward-table grants and marks completion idempotently (NEO-105).
/// Combat defeat wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
/// </summary>
public static class EncounterCompletionOperations
{
private static readonly EncounterGrantApplied[] EmptyGrants = [];
/// <summary>
/// When all required targets are defeated and the encounter is not yet completed, applies
/// <paramref name="encounterId"/>'s reward-table fixed grants then marks completion.
/// </summary>
public static EncounterCompletionResult TryCompleteAndGrant(
string playerId,
string encounterId,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var encounterDefinition))
{
return Deny(EncounterCompletionReasonCodes.UnknownEncounter);
}
if (completionStore.IsCompleted(playerId, normalizedEncounterId))
{
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
playerId,
normalizedEncounterId,
encounterRegistry,
progressStore))
{
return Deny(EncounterCompletionReasonCodes.NotReady);
}
if (!rewardTableRegistry.TryGetDefinition(encounterDefinition.RewardTableId, out var rewardTable))
{
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
var grants = rewardTable.FixedGrants;
if (grants.Count == 0)
{
// Prototype catalog gates prevent empty tables at startup; dedicated reason if zero-grant tables ship later.
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot))
{
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
var simulated = snapshot.Clone();
foreach (var grant in grants)
{
if (!PlayerInventoryOperations.TrySimulateAddStack(
ref simulated,
grant.ItemId,
grant.Quantity,
itemRegistry))
{
return Deny(EncounterCompletionReasonCodes.InventoryFull);
}
}
var applied = new List<EncounterGrantApplied>(grants.Count);
foreach (var grant in grants)
{
var addOutcome = PlayerInventoryOperations.TryAddStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(MapInventoryReason(addOutcome.ReasonCode));
}
applied.Add(new EncounterGrantApplied(grant.ItemId, grant.Quantity));
}
var completedAt = timeProvider.GetUtcNow();
if (!completionStore.TryMarkCompleted(playerId, normalizedEncounterId, completedAt))
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook).
var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId);
var completeEvent = new EncounterCompleteEvent(
normalizedPlayerId,
normalizedEncounterId,
rewardTable.Id,
applied,
completedAt,
MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId));
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,
GrantsApplied: applied,
CompleteEvent: completeEvent,
CompletedAt: completedAt);
}
private static string MapInventoryReason(string? reasonCode) =>
reasonCode switch
{
PlayerInventoryReasonCodes.InventoryFull => EncounterCompletionReasonCodes.InventoryFull,
PlayerInventoryReasonCodes.InvalidItem => EncounterCompletionReasonCodes.InvalidItem,
PlayerInventoryReasonCodes.InvalidQuantity => EncounterCompletionReasonCodes.InvalidQuantity,
null => EncounterCompletionReasonCodes.InventoryFull,
_ => reasonCode,
};
private static string MakeIdempotencyKey(string normalizedPlayerId, string normalizedEncounterId) =>
$"{normalizedPlayerId}:{normalizedEncounterId}";
private static void CompensatingRemoveGrants(
string playerId,
IReadOnlyList<EncounterGrantApplied> grants,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore)
{
for (var i = grants.Count - 1; i >= 0; i--)
{
var grant = grants[i];
_ = PlayerInventoryOperations.TryRemoveStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
}
}
private static EncounterCompletionResult Deny(string reasonCode)
{
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_complete_denied` ---
// TODO(E9.M1): catalog emit — structured deny with reasonCode (EncounterCompletionReasonCodes).
// Planned payload: playerId, encounterId, reasonCode. No ingest or ILogger here (comments-only).
return new EncounterCompletionResult(
Success: false,
ReasonCode: reasonCode,
GrantsApplied: EmptyGrants,
CompleteEvent: null,
CompletedAt: null);
}
}

View File

@ -0,0 +1,26 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Stable deny reason codes for encounter completion operations (NEO-105).</summary>
public static class EncounterCompletionReasonCodes
{
public const string AlreadyCompleted = "already_completed";
public const string NotReady = "not_ready";
public const string UnknownEncounter = "unknown_encounter";
public const string UnknownRewardTable = "unknown_reward_table";
public const string InventoryStoreMissing = "inventory_store_missing";
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InventoryFull"/>.</summary>
public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull;
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidItem"/>.</summary>
public const string InvalidItem = PlayerInventoryReasonCodes.InvalidItem;
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidQuantity"/>.</summary>
public const string InvalidQuantity = PlayerInventoryReasonCodes.InvalidQuantity;
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Server-internal encounter completion resolution envelope (NEO-105).
/// Combat wiring promotes via NEO-106; HTTP DTOs via NEO-108.
/// </summary>
/// <param name="GrantsApplied">Item grants on success; empty when denied.</param>
/// <param name="CompleteEvent">Completion payload on success; null when denied.</param>
public readonly record struct EncounterCompletionResult(
bool Success,
string? ReasonCode,
IReadOnlyList<EncounterGrantApplied> GrantsApplied,
EncounterCompleteEvent? CompleteEvent,
DateTimeOffset? CompletedAt);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>One item grant applied on a successful encounter completion (NEO-105).</summary>
public readonly record struct EncounterGrantApplied(string ItemId, int Quantity);

View File

@ -41,6 +41,14 @@ public sealed class PlayerInventorySnapshot
return copy;
}
/// <summary>Deep-copies bag and equipment slot arrays (NEO-69 craft pre-flight, NEO-105 encounter grant pre-flight).</summary>
public PlayerInventorySnapshot Clone() =>
new()
{
BagSlots = CloneSlots(BagSlots),
EquipmentSlots = CloneSlots(EquipmentSlots),
};
public InventorySlotState[] GetSlots(InventoryContainerKind container) =>
container == InventoryContainerKind.Equipment ? EquipmentSlots : BagSlots;

View File

@ -153,6 +153,21 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act
Combat cast wiring (**`AbilityCastApi`**) and **`GET /game/players/{id}/encounter-progress`** land in NEO-106 / NEO-108. Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md).
## Encounter completion + inventory grants (NEO-105)
**`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward tables **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. Returns internal **`EncounterCompleteEvent`** on success (persistence + E7.M2 hook: NEO-107).
| Reason code | When |
|-------------|------|
| **`already_completed`** | Encounter already marked complete for this player. |
| **`not_ready`** | Not all **`requiredNpcInstanceIds`** defeated yet. |
| **`unknown_encounter`** | **`encounterId`** not in **`IEncounterDefinitionRegistry`**. |
| **`unknown_reward_table`** | Encounters **`rewardTableId`** missing or empty grants. |
| **`inventory_full`** | Pre-flight or apply could not place full grant quantities. |
| **`inventory_store_missing`** | Player inventory store could not read/write. |
Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** from **`prototype_combat_pocket_clear`**. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Combat wiring lands in NEO-106. Plan: [NEO-105 implementation plan](../../docs/plans/NEO-105-implementation-plan.md).
## NPC behavior definitions (NEO-90)
**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.