Merge pull request #143 from ViPro-Technologies/NEO-104-encounter-progress-completion-stores
NEO-104: E5M3-05 encounter progress + completion storespull/144/head
commit
719a14f110
|
|
@ -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) → **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) → **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
|
||||
|
|
@ -86,6 +86,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
|
|||
|
||||
**HTTP read model landed ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + DTOs in `Game/Encounters/`; nested **`rewardTable`** summary via **`IRewardTableDefinitionRegistry`**. Plan: [NEO-103 implementation plan](../../plans/NEO-103-implementation-plan.md); [server README — Encounter definitions](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`.
|
||||
|
||||
**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).
|
||||
|
||||
## 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
|
|
@ -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-92–NEO-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/`. **Runtime/engine (NEO-104+) not started.**
|
||||
**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.**
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
|
|
|
|||
|
|
@ -185,9 +185,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Defeating required NPCs accumulates progress in any order.
|
||||
- [ ] Completion predicate true only when all required ids defeated.
|
||||
- [ ] Second completion attempt on same player+encounter is idempotent no-op at store layer.
|
||||
- [x] Defeating required NPCs accumulates progress in any order.
|
||||
- [x] Completion predicate true only when all required ids defeated.
|
||||
- [x] Second completion attempt on same player+encounter is idempotent no-op at store layer.
|
||||
|
||||
**Landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** `IEncounterProgressStore` + `IEncounterCompletionStore` + `EncounterProgressOperations`; in-memory DI; AAA unit tests; plan [NEO-104-implementation-plan.md](NEO-104-implementation-plan.md).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
# NEO-104 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-104 |
|
||||
| **Title** | E5M3-05: Encounter progress + completion stores |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-104/e5m3-05-encounter-progress-completion-stores |
|
||||
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-05** |
|
||||
| **Branch** | `NEO-104-encounter-progress-completion-stores` |
|
||||
| **Precursor** | [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) — `GET /game/world/encounter-definitions` (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-61](NEO-61-implementation-plan.md) / `InMemoryResourceNodeInstanceStore` — keyed in-memory store + static operations; [NEO-44](NEO-44-implementation-plan.md) — gig progression store DI |
|
||||
| **Blocks** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompletionOperations` + inventory reward apply |
|
||||
| **Client counterpart** | None this story — player-visible encounter HUD starts at [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) `GET /game/players/{id}/encounter-progress` |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Storage backend** | In-memory only vs Postgres when configured? | **In-memory only** — E5M3 kickoff default; session prototype. | **Adopted** — in-memory only |
|
||||
| **Progress vs completion** | Single store vs split? | **Split stores** — progress: `started` + `defeatedNpcInstanceIds`; completion: idempotent `completedAt`; operations expose `IsAllRequiredTargetsDefeated`. | **Adopted** — split stores |
|
||||
| **Encounter binding** | Explicit `encounterId` vs auto-resolve from NPC? | **Auto-resolve** — scan `IEncounterDefinitionRegistry` for encounter whose `requiredNpcInstanceIds` contains the NPC. | **Adopted** — auto-resolve from `npcInstanceId` |
|
||||
| **Combat / HTTP wiring** | Wire `AbilityCastApi` or HTTP on this story? | **Defer** — E5M3-07 / E5M3-08; NEO-104 ships stores + operations + unit tests only. | **Adopted** (implicit — not asked; backlog out-of-scope) |
|
||||
| **Activation before defeat** | Auto-activate on `TryMarkTargetDefeated` vs require prior `TryActivateOnFirstEngagement`? | **Require activation** — `TryMarkTargetDefeated` no-ops when not started; activation is separate (NEO-106 wires first damaging cast). | **Adopted** (plan default; backlog activation hook) |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Server-owned per-player encounter **activation**, **defeat progress** (order-independent subset of `requiredNpcInstanceIds`), and an **idempotent completion flag** at the store layer — plus **`EncounterProgressOperations`** that resolve encounters from NPC ids via **`IEncounterDefinitionRegistry`**.
|
||||
|
||||
**In scope (from Linear + [E5M3-05](E5M3-prototype-backlog.md#e5m3-05--encounter-progress--completion-stores)):**
|
||||
|
||||
- **`IEncounterProgressStore`** — keyed by **`(playerId, encounterId)`**; fields: **`started`** (bool), **`defeatedNpcInstanceIds`** (set, normalized lowercase).
|
||||
- **`IEncounterCompletionStore`** — **`TryMarkCompleted`**, **`IsCompleted`**, **`TryGetCompletedAt`**; second **`TryMarkCompleted`** is no-op (`false`).
|
||||
- **`InMemoryEncounterProgressStore`**, **`InMemoryEncounterCompletionStore`** — thread-safe `ConcurrentDictionary` + per-key locks (mirror gathering/NPC stores).
|
||||
- **`EncounterProgressOperations`**:
|
||||
- **`TryActivateOnFirstEngagement(playerId, npcInstanceId, …)`** — resolve encounter; set `started` if not started and not completed.
|
||||
- **`TryMarkTargetDefeated(playerId, npcInstanceId, …)`** — requires started + not completed; add NPC to defeated set if in encounter’s required list; idempotent re-mark of same NPC returns success without duplicate progress.
|
||||
- **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — compares defeated set to registry `requiredNpcInstanceIds` (set equality).
|
||||
- DI registration in **`EncounterCatalogServiceCollectionExtensions`** or new **`EncounterProgressServiceCollectionExtensions`**.
|
||||
- **`InMemoryWebApplicationFactory`** — register in-memory encounter stores (remove any Postgres placeholder if added later).
|
||||
- Unit tests (AAA) with injected fake stores / registry.
|
||||
- `server/README.md` encounter progress section (brief).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Inventory grants, **`EncounterCompleteEvent`**, **`EncounterCompletionOperations`** ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)).
|
||||
- Combat defeat / ability-cast wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)).
|
||||
- HTTP **`GET /game/players/{id}/encounter-progress`** ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
|
||||
- Postgres persistence, Godot, Bruno, `docs/manual-qa/NEO-104.md`.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Defeating required NPCs accumulates progress in any order (via `TryMarkTargetDefeated` after activation).
|
||||
- [x] Completion predicate true only when all required ids defeated (`IsAllRequiredTargetsDefeated`).
|
||||
- [x] Second completion attempt on same player+encounter is idempotent no-op at store layer (`TryMarkCompleted` returns `false`).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Stores:** `IEncounterProgressStore` / `InMemoryEncounterProgressStore`; `IEncounterCompletionStore` / `InMemoryEncounterCompletionStore`; `EncounterProgressIds` normalization.
|
||||
- **Operations:** `EncounterProgressOperations` — `TryResolveEncounterForNpc`, `TryActivateOnFirstEngagement`, `TryMarkTargetDefeated`, `IsAllRequiredTargetsDefeated`.
|
||||
- **DI:** registered in `AddEncounterAndRewardCatalogs`.
|
||||
- **Tests:** `InMemoryEncounterProgressStoreTests`, `InMemoryEncounterCompletionStoreTests`, `EncounterProgressOperationsTests`; host DI resolve in `EncounterDefinitionRegistryTests`.
|
||||
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
|
||||
- **Plan file list:** `Program.cs` and `InMemoryWebApplicationFactory.cs` unchanged — stores register in existing `AddEncounterAndRewardCatalogs` (already invoked at startup); always in-memory (no Postgres test-factory swap).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Snapshots / keys**
|
||||
- **`EncounterProgressSnapshot`**: `PlayerId`, `EncounterId`, `Started`, `DefeatedNpcInstanceIds` (read-only set).
|
||||
- **`EncounterPlayerEncounterKey`**: normalize `playerId` + `encounterId` with trim + lowercase (match registry / position-state id normalization).
|
||||
- NPC ids normalized the same way when stored and compared.
|
||||
|
||||
2. **`IEncounterProgressStore`**
|
||||
- `TryGetProgress(playerId, encounterId, out snapshot)` — missing row ⇒ not started, empty defeated set.
|
||||
- `TryActivate(playerId, encounterId)` — creates row with `started: true`, empty defeated set; returns `true` on first activation, `false` if already started (idempotent) or if completion store says completed.
|
||||
- `TryAddDefeatedTarget(playerId, encounterId, npcInstanceId)` — requires existing started row; add to set; return `true` if newly added, `false` if duplicate NPC or not started.
|
||||
|
||||
3. **`IEncounterCompletionStore`**
|
||||
- `bool IsCompleted(playerId, encounterId)`
|
||||
- `bool TryMarkCompleted(playerId, encounterId, DateTimeOffset completedAt)` — `true` first time, `false` if already completed.
|
||||
- `bool TryGetCompletedAt(…, out DateTimeOffset completedAt)`
|
||||
|
||||
4. **`EncounterProgressOperations`** (static, mirror `GatherOperations` / `AggroOperations`)
|
||||
- **`TryResolveEncounterForNpc(npcInstanceId, registry, out encounterId, out definition)`** — scan `GetDefinitionsInIdOrder()` for first encounter where `requiredNpcInstanceIds` contains normalized NPC (prototype: single `prototype_combat_pocket`).
|
||||
- **`TryActivateOnFirstEngagement`**: resolve encounter → check completion store → `TryActivate` on progress store.
|
||||
- **`TryMarkTargetDefeated`**: resolve encounter → deny if completed → deny if not started → validate NPC in required list → `TryAddDefeatedTarget`.
|
||||
- **`IsAllRequiredTargetsDefeated`**: load progress + definition; compare required set to defeated set (order-independent).
|
||||
- Inject **`TimeProvider`** only where completion store needs timestamps (completion store accepts `completedAt` from caller; NEO-105 passes `TimeProvider.GetUtcNow()`).
|
||||
|
||||
5. **DI**
|
||||
- `services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();`
|
||||
- `services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();`
|
||||
- Extend **`AddEncounterAndRewardCatalogs`** or add **`AddEncounterProgressStores`** called from **`Program.cs`** after encounter catalogs.
|
||||
|
||||
6. **Reserved hook**
|
||||
- XML comment on **`TryActivateOnFirstEngagement`**: `// TODO(E9.M1): encounter_start telemetry` (activates E5M3-07 hook site).
|
||||
|
||||
7. **Docs**
|
||||
- `server/README.md` — stores + operations, note combat/HTTP deferred.
|
||||
- Update [E5_M3](E5_M3_EncounterAndRewardTables.md) implementation snapshot when landed.
|
||||
|
||||
### Prototype encounter behavior (frozen)
|
||||
|
||||
| Step | Expected |
|
||||
|------|----------|
|
||||
| Activate | `TryActivateOnFirstEngagement(devPlayer, prototype_npc_melee)` → started, 0/3 defeated |
|
||||
| Defeat 2 | Mark melee + ranged → `IsAllRequiredTargetsDefeated` false |
|
||||
| Defeat 3 | Mark elite → `IsAllRequiredTargetsDefeated` true |
|
||||
| Complete store | `TryMarkCompleted` once → true; second → false |
|
||||
| Order | elite → melee → ranged also reaches all-defeated |
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressSnapshot.cs` | Immutable progress row for store read model. |
|
||||
| `server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs` | Progress store contract. |
|
||||
| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs` | Thread-safe in-memory implementation. |
|
||||
| `server/NeonSprawl.Server/Game/Encounters/IEncounterCompletionStore.cs` | Completion store contract. |
|
||||
| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs` | Thread-safe in-memory implementation. |
|
||||
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs` | Static orchestration: resolve encounter, activate, mark defeat, all-defeated predicate. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs` | AAA unit tests: activation, any-order defeats, all-defeated predicate, completion idempotency. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterProgressStoreTests.cs` | AAA store tests: activate idempotency, duplicate defeat, not-started deny. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompletionStoreTests.cs` | AAA completion idempotency tests. |
|
||||
| `docs/plans/NEO-104-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | Register `IEncounterProgressStore` + `IEncounterCompletionStore` singletons (or call new extension from here). |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Ensure encounter progress stores registered at startup (if split extension, invoke it). |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Swap in test in-memory encounter stores; strip production registrations in test host. |
|
||||
| `server/README.md` | Document encounter progress/completion stores and operations; defer HTTP/combat to NEO-106/108. |
|
||||
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — progress/completion stores (NEO-104). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-104 stores when landed. |
|
||||
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-05 AC when complete. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `InMemoryEncounterProgressStoreTests.cs` | **Arrange:** store. **Act/Assert:** first `TryActivate` true; second false; `TryAddDefeatedTarget` without activate false; duplicate NPC add false; two distinct NPCs in set. |
|
||||
| `InMemoryEncounterCompletionStoreTests.cs` | **Arrange:** store + fixed `completedAt`. **Act:** `TryMarkCompleted` twice. **Assert:** first true, second false; `IsCompleted` true; `TryGetCompletedAt` matches. |
|
||||
| `EncounterProgressOperationsTests.cs` | **Arrange:** real `EncounterDefinitionRegistry` from test factory catalog + in-memory stores. **Act/Assert:** activate on `prototype_npc_melee`; defeat three NPCs in permuted order → `IsAllRequiredTargetsDefeated` true only after third; partial progress false; `TryMarkTargetDefeated` before activate false; `TryMarkCompleted` idempotent; progress blocked when already completed. |
|
||||
| `EncounterDefinitionRegistryTests.cs` (optional host test) | **Arrange:** `InMemoryWebApplicationFactory`. **Assert:** resolves `IEncounterProgressStore` + `IEncounterCompletionStore` from DI. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Multiple encounters sharing one NPC** | First match in `GetDefinitionsInIdOrder()` wins — acceptable for prototype (single encounter). Document in operations XML. | **adopted** |
|
||||
| **Mark defeat after encounter completed** | `TryMarkTargetDefeated` returns false — fail closed. | **adopted** |
|
||||
| **Postgres follow-on** | Defer — new migration + store pair in a future issue when session persistence is required. | **deferred** |
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# Code review — NEO-104 (E5M3-05)
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Scope:** Branch `NEO-104-encounter-progress-completion-stores` vs `aa81914` (merge-base on `main`) — commits `727ce47` … `aaccbbb`
|
||||
**Base:** `aa81914` (main at branch point, post NEO-103 merge)
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-104 adds server-owned per-player encounter **activation**, **defeat progress** (order-independent subset of `requiredNpcInstanceIds`), and an **idempotent completion flag** at the store layer, plus static **`EncounterProgressOperations`** that auto-resolve encounters from NPC ids via **`IEncounterDefinitionRegistry`**. Implementation mirrors established patterns (`InMemoryResourceNodeInstanceStore` per-key locks, `GatherOperations` / `AggroOperations` static orchestration): split **`IEncounterProgressStore`** / **`IEncounterCompletionStore`**, `EncounterProgressIds` normalization, DI in **`AddEncounterAndRewardCatalogs`**, telemetry hook comment on activation, and ten new AAA unit tests plus a host DI smoke in **`EncounterDefinitionRegistryTests`**. Docs (plan, backlog, alignment register, module register, E5.M3 snapshot, `server/README.md`) are updated. Combat cast and HTTP read are correctly deferred to NEO-106 / NEO-108; no client counterpart required. Risk is low: in-memory stores with no player-visible surface until NEO-108 / NEO-110.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-104-implementation-plan.md` | **Matches** — split stores, operations API, in-memory DI, deferred combat/HTTP, kickoff decisions recorded, acceptance checklist checked, reconciliation section accurate. |
|
||||
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-05) | **Matches** — acceptance criteria checked; landed note cites plan. |
|
||||
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Summary **Status** marks **E5M3-05 / NEO-104** landed; **Implementation snapshot** progress bullet added. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-104** stores; **NEO-105+** grants/combat/HTTP still outstanding. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-104** store landing; **NEO-105+** outstanding. |
|
||||
| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 via NEO-108. |
|
||||
| `server/README.md` | **Matches** — encounter progress section documents stores, operations, and deferred NEO-106/108 wiring. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Plan file list vs shipped diff** — The plan lists **`InMemoryWebApplicationFactory.cs`** and **`Program.cs`** as files to modify; neither changed. This is acceptable: stores register in the existing **`AddEncounterAndRewardCatalogs`** extension (already invoked at startup), and encounter stores are always in-memory (no Postgres swap needed in the test factory, unlike position/inventory stores). Consider a one-line note in the plan **Implementation reconciliation** that factory/Program edits were N/A.~~ **Done.** Note added to plan **Implementation reconciliation**.
|
||||
|
||||
2. ~~**Completion guard on `TryActivate`** — The plan describes **`TryActivate`** returning `false` when the completion store marks the encounter done. Gating lives only in **`EncounterProgressOperations.TryActivateOnFirstEngagement`**; a direct **`IEncounterProgressStore.TryActivate`** call after completion would still create a started row. Acceptable while NEO-106/105 route exclusively through operations; add a brief XML note on **`IEncounterProgressStore.TryActivate`** that completion gating is the caller's responsibility if direct store use is anticipated.~~ **Done.** XML on **`IEncounterProgressStore.TryActivate`**.
|
||||
|
||||
3. ~~**Optional operations coverage** — Store and core happy-path operations are well covered. Consider adding AAA tests for: **`TryActivateOnFirstEngagement`** returns `false` when encounter is already completed; resolve/activate returns `false` for an NPC id not in any encounter definition.~~ **Done.** Two new tests in **`EncounterProgressOperationsTests`** plus mixed-case **`IsAllRequiredTargetsDefeated`** coverage.
|
||||
|
||||
4. ~~**`IsAllRequiredTargetsDefeated` id normalization** — The method passes **`encounterId`** to case-sensitive **`TryGetDefinition`**. NEO-102 precedent documents that only **`TryNormalizeKnown`** lowercases. Internal callers today use catalog **`row.Id`** (lowercase); NEO-108 HTTP should normalize before calling — optionally harden now with **`TryNormalizeKnown`** or **`EncounterProgressIds.NormalizeEncounterId`** + lookup for defense in depth.~~ **Done.** **`TryNormalizeKnown`** before lookup and progress read.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds`** — **`Assert.NotNull`** on values from **`GetRequiredService`** is redundant (missing registration throws). Minor AAA nit: DI resolution fits **Arrange**; **Act** could be only **`TryActivate`** / **`TryMarkCompleted`**.~~ **Done.**
|
||||
|
||||
- ~~Nit: **`InMemoryEncounterCompletionStore.IsCompleted`** reads **`ConcurrentDictionary`** without the per-key lock used by **`TryMarkCompleted`** / **`TryGetCompletedAt`**. Benign for prototype bool checks; align with lock if strict read-your-writes under concurrency matters later.~~ **Done.** **`IsCompleted`** uses per-key lock.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# NEO-104-focused (10 new tests + host DI smoke — passed locally)
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterProgressOperations|FullyQualifiedName~InMemoryEncounterProgressStore|FullyQualifiedName~InMemoryEncounterCompletionStore|Host_ShouldResolveEncounterProgressStoresFromDi"
|
||||
|
||||
# Broader encounter slice (catalog + registries + HTTP + progress)
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
|
||||
|
||||
# CI parity
|
||||
python3 scripts/validate_content.py
|
||||
```
|
||||
|
||||
**Local run:** 52/52 `Encounters` filter tests passed.
|
||||
|
|
@ -202,4 +202,25 @@ public class EncounterDefinitionRegistryTests
|
|||
Assert.Single(encounterList);
|
||||
Assert.Equal(PrototypeCombatPocket, encounterList[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
// Act
|
||||
var activated = progressStore.TryActivate("dev-local-1", PrototypeCombatPocket);
|
||||
var completed = completionStore.TryMarkCompleted(
|
||||
"dev-local-1",
|
||||
PrototypeCombatPocket,
|
||||
new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero));
|
||||
// Assert
|
||||
Assert.True(activated);
|
||||
Assert.True(completed);
|
||||
Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
public sealed class EncounterProgressOperationsTests
|
||||
{
|
||||
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 TryActivateOnFirstEngagement_ShouldStartEncounter_WhenNpcIsRequired()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
// Act
|
||||
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
|
||||
PlayerId,
|
||||
MeleeNpc,
|
||||
registry,
|
||||
progress,
|
||||
completion);
|
||||
// Assert
|
||||
Assert.True(activated);
|
||||
Assert.True(progress.TryGetProgress(PlayerId, EncounterId, out var snapshot));
|
||||
Assert.True(snapshot.Started);
|
||||
Assert.Empty(snapshot.DefeatedNpcInstanceIds);
|
||||
Assert.False(EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryMarkTargetDefeated_ShouldReturnFalse_WhenNotActivated()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
// Act
|
||||
var marked = EncounterProgressOperations.TryMarkTargetDefeated(
|
||||
PlayerId,
|
||||
MeleeNpc,
|
||||
registry,
|
||||
progress,
|
||||
completion);
|
||||
// Assert
|
||||
Assert.False(marked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAllRequiredTargetsDefeated_ShouldBecomeTrue_AfterThirdDefeatInAnyOrder()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, EliteNpc, registry, progress, completion);
|
||||
// Act
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, EliteNpc, registry, progress, completion);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion);
|
||||
var afterTwo = EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, RangedNpc, registry, progress, completion);
|
||||
var afterThree = EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress);
|
||||
// Assert
|
||||
Assert.False(afterTwo);
|
||||
Assert.True(afterThree);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryMarkTargetDefeated_ShouldBeIdempotent_WhenNpcAlreadyDefeated()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion);
|
||||
// Act
|
||||
var secondMark = EncounterProgressOperations.TryMarkTargetDefeated(
|
||||
PlayerId,
|
||||
MeleeNpc,
|
||||
registry,
|
||||
progress,
|
||||
completion);
|
||||
// Assert
|
||||
Assert.True(secondMark);
|
||||
Assert.True(progress.TryGetProgress(PlayerId, EncounterId, out var snapshot));
|
||||
Assert.Single(snapshot.DefeatedNpcInstanceIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryMarkTargetDefeated_ShouldReturnFalse_WhenEncounterAlreadyCompleted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion);
|
||||
_ = completion.TryMarkCompleted(PlayerId, EncounterId, DateTimeOffset.UtcNow);
|
||||
// Act
|
||||
var marked = EncounterProgressOperations.TryMarkTargetDefeated(
|
||||
PlayerId,
|
||||
MeleeNpc,
|
||||
registry,
|
||||
progress,
|
||||
completion);
|
||||
// Assert
|
||||
Assert.False(marked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenEncounterAlreadyCompleted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
_ = completion.TryMarkCompleted(PlayerId, EncounterId, DateTimeOffset.UtcNow);
|
||||
// Act
|
||||
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
|
||||
PlayerId,
|
||||
MeleeNpc,
|
||||
registry,
|
||||
progress,
|
||||
completion);
|
||||
// Assert
|
||||
Assert.False(activated);
|
||||
Assert.False(progress.TryGetProgress(PlayerId, EncounterId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenNpcNotInAnyEncounter()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
// Act
|
||||
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
|
||||
PlayerId,
|
||||
"prototype_npc_unknown",
|
||||
registry,
|
||||
progress,
|
||||
completion);
|
||||
// Assert
|
||||
Assert.False(activated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAllRequiredTargetsDefeated_ShouldReturnTrue_WhenEncounterIdHasMixedCase()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, RangedNpc, registry, progress, completion);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, EliteNpc, registry, progress, completion);
|
||||
// Act
|
||||
var allDefeated = EncounterProgressOperations.IsAllRequiredTargetsDefeated(
|
||||
PlayerId,
|
||||
" Prototype_Combat_Pocket ",
|
||||
registry,
|
||||
progress);
|
||||
// Assert
|
||||
Assert.True(allDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAllRequiredTargetsDefeated_ShouldReturnTrue_WhenDefeatedSetIsSupersetOfRequired()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
|
||||
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
|
||||
_ = progress.TryActivate(PlayerId, EncounterId);
|
||||
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
|
||||
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, RangedNpc);
|
||||
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, EliteNpc);
|
||||
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, "extra_non_required_npc");
|
||||
// Act
|
||||
var allDefeated = EncounterProgressOperations.IsAllRequiredTargetsDefeated(
|
||||
PlayerId,
|
||||
EncounterId,
|
||||
registry,
|
||||
progress);
|
||||
// Assert
|
||||
Assert.True(allDefeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryMarkCompleted_ShouldReturnFalseOnSecondAttempt_WhenUsingCompletionStoreDirectly()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
|
||||
var completedAt = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
|
||||
// Act
|
||||
var first = completion.TryMarkCompleted(PlayerId, EncounterId, completedAt);
|
||||
var second = completion.TryMarkCompleted(PlayerId, EncounterId, completedAt);
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.False(second);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
public sealed class InMemoryEncounterCompletionStoreTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string EncounterId = "prototype_combat_pocket";
|
||||
private static readonly DateTimeOffset CompletedAt = new(2026, 5, 31, 18, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryMarkCompleted_ShouldBeIdempotent_AtStoreLayer()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryEncounterCompletionStore();
|
||||
// Act
|
||||
var first = store.TryMarkCompleted(PlayerId, EncounterId, CompletedAt);
|
||||
var second = store.TryMarkCompleted(PlayerId, EncounterId, CompletedAt.AddHours(1));
|
||||
var hasCompletedAt = store.TryGetCompletedAt(PlayerId, EncounterId, out var storedAt);
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.False(second);
|
||||
Assert.True(store.IsCompleted(PlayerId, EncounterId));
|
||||
Assert.True(hasCompletedAt);
|
||||
Assert.Equal(CompletedAt, storedAt);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Encounters;
|
||||
|
||||
public sealed class InMemoryEncounterProgressStoreTests
|
||||
{
|
||||
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";
|
||||
|
||||
[Fact]
|
||||
public void TryActivate_ShouldReturnTrueThenFalse_WhenCalledTwice()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryEncounterProgressStore();
|
||||
// Act
|
||||
var first = store.TryActivate(PlayerId, EncounterId);
|
||||
var second = store.TryActivate(PlayerId, EncounterId);
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.False(second);
|
||||
Assert.True(store.TryGetProgress(PlayerId, EncounterId, out var progress));
|
||||
Assert.True(progress.Started);
|
||||
Assert.Empty(progress.DefeatedNpcInstanceIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAddDefeatedTarget_ShouldReturnFalse_WhenEncounterNotActivated()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryEncounterProgressStore();
|
||||
// Act
|
||||
var added = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
|
||||
// Assert
|
||||
Assert.False(added);
|
||||
Assert.False(store.TryGetProgress(PlayerId, EncounterId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAddDefeatedTarget_ShouldReturnFalseOnDuplicate_AndTrueForSecondNpc()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryEncounterProgressStore();
|
||||
_ = store.TryActivate(PlayerId, EncounterId);
|
||||
// Act
|
||||
var firstAdd = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
|
||||
var duplicate = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
|
||||
var secondAdd = store.TryAddDefeatedTarget(PlayerId, EncounterId, RangedNpc);
|
||||
// Assert
|
||||
Assert.True(firstAdd);
|
||||
Assert.False(duplicate);
|
||||
Assert.True(secondAdd);
|
||||
Assert.True(store.TryGetProgress(PlayerId, EncounterId, out var progress));
|
||||
Assert.Equal(2, progress.DefeatedNpcInstanceIds.Count);
|
||||
Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds);
|
||||
Assert.Contains(RangedNpc, progress.DefeatedNpcInstanceIds);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,9 @@ public static class EncounterCatalogServiceCollectionExtensions
|
|||
services.AddSingleton<IRewardTableDefinitionRegistry>(sp =>
|
||||
new RewardTableDefinitionRegistry(sp.GetRequiredService<RewardTableDefinitionCatalog>()));
|
||||
|
||||
services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();
|
||||
services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Id normalization for encounter progress/completion stores (NEO-104).</summary>
|
||||
public static class EncounterProgressIds
|
||||
{
|
||||
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||
public static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var trimmed = playerId?.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return trimmed.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||
public static string NormalizeEncounterId(string? encounterId) => NormalizePlayerId(encounterId);
|
||||
|
||||
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
|
||||
public static string NormalizeNpcInstanceId(string? npcInstanceId) => NormalizePlayerId(npcInstanceId);
|
||||
|
||||
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="encounterId"/>.</summary>
|
||||
public static string MakeProgressKey(string? playerId, string? encounterId)
|
||||
{
|
||||
var p = NormalizePlayerId(playerId);
|
||||
var e = NormalizeEncounterId(encounterId);
|
||||
if (p.Length == 0 || e.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{p}\0{e}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>
|
||||
/// Encounter activation and defeat-all progress orchestration (NEO-104).
|
||||
/// Combat cast wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
|
||||
/// </summary>
|
||||
public static class EncounterProgressOperations
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the first encounter (id order) whose <c>requiredNpcInstanceIds</c> contains <paramref name="npcInstanceId"/>.
|
||||
/// When multiple encounters share an NPC, the earliest id wins.
|
||||
/// </summary>
|
||||
public static bool TryResolveEncounterForNpc(
|
||||
string npcInstanceId,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
out string encounterId,
|
||||
out EncounterDefRow definition)
|
||||
{
|
||||
encounterId = string.Empty;
|
||||
definition = null!;
|
||||
var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId);
|
||||
if (npcKey.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var row in encounterRegistry.GetDefinitionsInIdOrder())
|
||||
{
|
||||
if (row.RequiredNpcInstanceIds.Any(id =>
|
||||
string.Equals(
|
||||
EncounterProgressIds.NormalizeNpcInstanceId(id),
|
||||
npcKey,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
encounterId = row.Id;
|
||||
definition = row;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates encounter progress on first engagement with a required NPC.
|
||||
/// No-op when already started, completed, or NPC is not part of any encounter.
|
||||
/// </summary>
|
||||
public static bool TryActivateOnFirstEngagement(
|
||||
string playerId,
|
||||
string npcInstanceId,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IEncounterProgressStore progressStore,
|
||||
IEncounterCompletionStore completionStore)
|
||||
{
|
||||
if (!TryResolveEncounterForNpc(npcInstanceId, encounterRegistry, out var encounterId, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (completionStore.IsCompleted(playerId, encounterId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_start` ---
|
||||
// TODO(E9.M1): catalog emit — on first activation for player+encounter (NEO-106 wires cast path).
|
||||
return progressStore.TryActivate(playerId, encounterId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records one required NPC defeat. Requires prior activation; idempotent when the NPC was already marked defeated.
|
||||
/// </summary>
|
||||
public static bool TryMarkTargetDefeated(
|
||||
string playerId,
|
||||
string npcInstanceId,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IEncounterProgressStore progressStore,
|
||||
IEncounterCompletionStore completionStore)
|
||||
{
|
||||
if (!TryResolveEncounterForNpc(npcInstanceId, encounterRegistry, out var encounterId, out var definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (completionStore.IsCompleted(playerId, encounterId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId);
|
||||
if (!definition.RequiredNpcInstanceIds.Any(id =>
|
||||
string.Equals(EncounterProgressIds.NormalizeNpcInstanceId(id), npcKey, StringComparison.Ordinal)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!progressStore.TryGetProgress(playerId, encounterId, out var progress) || !progress.Started)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (progress.DefeatedNpcInstanceIds.Contains(npcKey))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return progressStore.TryAddDefeatedTarget(playerId, encounterId, npcInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when every <c>requiredNpcInstanceIds</c> entry is in the defeated set for a started encounter.
|
||||
/// </summary>
|
||||
public static bool IsAllRequiredTargetsDefeated(
|
||||
string playerId,
|
||||
string encounterId,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IEncounterProgressStore progressStore)
|
||||
{
|
||||
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
|
||||
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!progressStore.TryGetProgress(playerId, normalizedEncounterId, out var progress) || !progress.Started)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var required = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var id in definition.RequiredNpcInstanceIds)
|
||||
{
|
||||
var normalized = EncounterProgressIds.NormalizeNpcInstanceId(id);
|
||||
if (normalized.Length > 0)
|
||||
{
|
||||
required.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (required.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return required.IsSubsetOf(progress.DefeatedNpcInstanceIds);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Read model for per-player encounter defeat progress (NEO-104).</summary>
|
||||
public sealed class EncounterProgressSnapshot(
|
||||
string playerId,
|
||||
string encounterId,
|
||||
bool started,
|
||||
IReadOnlySet<string> defeatedNpcInstanceIds)
|
||||
{
|
||||
public string PlayerId { get; } = playerId;
|
||||
|
||||
public string EncounterId { get; } = encounterId;
|
||||
|
||||
public bool Started { get; } = started;
|
||||
|
||||
public IReadOnlySet<string> DefeatedNpcInstanceIds { get; } = defeatedNpcInstanceIds;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Idempotent per-player encounter completion flag (NEO-104; rewards applied in NEO-105).</summary>
|
||||
public interface IEncounterCompletionStore
|
||||
{
|
||||
bool IsCompleted(string playerId, string encounterId);
|
||||
|
||||
/// <summary>First mark returns <c>true</c>; replays return <c>false</c>.</summary>
|
||||
bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt);
|
||||
|
||||
bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Per-player encounter activation and defeat-target progress (NEO-104).</summary>
|
||||
public interface IEncounterProgressStore
|
||||
{
|
||||
/// <summary>Returns <c>false</c> when no row exists for the player+encounter pair.</summary>
|
||||
bool TryGetProgress(string playerId, string encounterId, out EncounterProgressSnapshot snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a started row with an empty defeated set.
|
||||
/// Returns <c>true</c> on first activation; <c>false</c> when already started.
|
||||
/// Does not consult <see cref="IEncounterCompletionStore"/> — callers that must block activation after completion
|
||||
/// (e.g. <see cref="EncounterProgressOperations.TryActivateOnFirstEngagement"/>) must gate before calling this method.
|
||||
/// </summary>
|
||||
bool TryActivate(string playerId, string encounterId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds one defeated NPC id to an existing started row.
|
||||
/// Returns <c>true</c> when newly added; <c>false</c> when duplicate, not started, or invalid ids.
|
||||
/// </summary>
|
||||
bool TryAddDefeatedTarget(string playerId, string encounterId, string npcInstanceId);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Thread-safe in-memory encounter completion flags (NEO-104).</summary>
|
||||
public sealed class InMemoryEncounterCompletionStore : IEncounterCompletionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> completedAtByKey = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsCompleted(string playerId, string encounterId)
|
||||
{
|
||||
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
return completedAtByKey.ContainsKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt)
|
||||
{
|
||||
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (completedAtByKey.ContainsKey(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
completedAtByKey[key] = completedAt;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt)
|
||||
{
|
||||
completedAt = default;
|
||||
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
return completedAtByKey.TryGetValue(key, out completedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
/// <summary>Thread-safe in-memory encounter progress; empty at startup (NEO-104).</summary>
|
||||
public sealed class InMemoryEncounterProgressStore : IEncounterProgressStore
|
||||
{
|
||||
private sealed class ProgressRow(bool started, HashSet<string> defeatedNpcInstanceIds)
|
||||
{
|
||||
public bool Started { get; } = started;
|
||||
|
||||
public HashSet<string> DefeatedNpcInstanceIds { get; } = defeatedNpcInstanceIds;
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<string, ProgressRow> byKey = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetProgress(string playerId, string encounterId, out EncounterProgressSnapshot snapshot)
|
||||
{
|
||||
snapshot = default!;
|
||||
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byKey.TryGetValue(key, out var row))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new EncounterProgressSnapshot(
|
||||
EncounterProgressIds.NormalizePlayerId(playerId),
|
||||
EncounterProgressIds.NormalizeEncounterId(encounterId),
|
||||
row.Started,
|
||||
new HashSet<string>(row.DefeatedNpcInstanceIds, StringComparer.Ordinal));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryActivate(string playerId, string encounterId)
|
||||
{
|
||||
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (byKey.TryGetValue(key, out var existing) && existing.Started)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byKey[key] = new ProgressRow(
|
||||
started: true,
|
||||
defeatedNpcInstanceIds: new HashSet<string>(StringComparer.Ordinal));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryAddDefeatedTarget(string playerId, string encounterId, string npcInstanceId)
|
||||
{
|
||||
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
|
||||
var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId);
|
||||
if (key.Length == 0 || npcKey.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
if (!byKey.TryGetValue(key, out var row) || !row.Started)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return row.DefeatedNpcInstanceIds.Add(npcKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,18 @@ On success, **Information** logs include the resolved encounters directory path,
|
|||
curl -sS -i "http://localhost:5253/game/world/encounter-definitions"
|
||||
```
|
||||
|
||||
## Encounter progress + completion stores (NEO-104)
|
||||
|
||||
Per-player encounter state is server-owned in **`IEncounterProgressStore`** (activation + defeated required NPC ids) and **`IEncounterCompletionStore`** (idempotent **`completedAt`** flag after rewards — marking used by NEO-105). Prototype uses in-memory singletons registered in **`AddEncounterAndRewardCatalogs`**.
|
||||
|
||||
**`EncounterProgressOperations`** (static):
|
||||
|
||||
- **`TryActivateOnFirstEngagement(playerId, npcInstanceId, …)`** — resolves encounter from NPC via **`IEncounterDefinitionRegistry`**, starts progress when not completed.
|
||||
- **`TryMarkTargetDefeated(…)`** — requires prior activation; accumulates defeats in any order; idempotent per NPC.
|
||||
- **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — set equality vs catalog **`requiredNpcInstanceIds`**.
|
||||
|
||||
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).
|
||||
|
||||
## 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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue