NEO-104: Add encounter progress and completion stores.

In-memory IEncounterProgressStore and IEncounterCompletionStore with
EncounterProgressOperations for activation, defeat tracking, and
all-targets-defeated checks; unit tests and docs.
pull/143/head
VinPropane 2026-05-31 16:18:56 -04:00
parent 727ce4774a
commit aaccbbbcaf
18 changed files with 656 additions and 9 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) → **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

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/`. **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

View File

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

View File

@ -51,9 +51,17 @@
## Acceptance criteria checklist
- [ ] Defeating required NPCs accumulates progress in any order (via `TryMarkTargetDefeated` after activation).
- [ ] Completion predicate true only when all required ids defeated (`IsAllRequiredTargetsDefeated`).
- [ ] Second completion attempt on same player+encounter is idempotent no-op at store layer (`TryMarkCompleted` returns `false`).
- [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.
## Technical approach

View File

@ -202,4 +202,27 @@ 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");
// Act
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
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.NotNull(progressStore);
Assert.NotNull(completionStore);
Assert.True(activated);
Assert.True(completed);
Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket));
}
}

View File

@ -0,0 +1,135 @@
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 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,146 @@
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.TryGetDefinition(encounterId, out var definition))
{
return false;
}
if (!progressStore.TryGetProgress(playerId, encounterId, 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.SetEquals(progress.DefeatedNpcInstanceIds);
}
}

View File

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

View File

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

View File

@ -0,0 +1,20 @@
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.
/// </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);
}

View File

@ -0,0 +1,55 @@
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);
return key.Length > 0 && 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);
}
}
}

View File

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

View File

@ -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/`.