NEO-107: Persist EncounterCompleteEvent and wire E7.M2 hook stub.

Add IEncounterCompleteEventStore with in-memory implementation, record
events on successful TryCompleteAndGrant, and expand reward_delivery
hook comments for future QuestRewardBundle routing.
pull/146/head
VinPropane 2026-05-31 18:26:11 -04:00
parent 986cf55e73
commit a02496c6bc
12 changed files with 185 additions and 5 deletions

View File

@ -31,6 +31,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -59,6 +60,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -91,6 +93,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -98,6 +101,8 @@ public sealed class EncounterCombatWiringTests
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.True(deps.CompleteEventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal($"{PlayerId}:{EncounterId}", storedEvent.IdempotencyKey);
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
@ -119,6 +124,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -142,6 +148,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -157,6 +164,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -186,6 +194,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -224,6 +233,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -259,6 +269,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -278,6 +289,7 @@ public sealed class EncounterCombatWiringTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -326,6 +338,7 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
}
@ -349,6 +362,7 @@ public sealed class EncounterCombatWiringTests
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
}

View File

@ -31,6 +31,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
@ -49,6 +50,9 @@ public sealed class EncounterCompletionOperationsTests
Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey);
Assert.NotNull(result.CompletedAt);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal(result.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey);
Assert.Equal(2, storedEvent.GrantedItems.Count);
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
@ -68,6 +72,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
@ -81,6 +86,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
@ -98,6 +104,8 @@ public sealed class EncounterCompletionOperationsTests
Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal(first.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey);
}
[Fact]
@ -134,6 +142,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -143,6 +152,7 @@ public sealed class EncounterCompletionOperationsTests
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
}
@ -164,6 +174,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -173,6 +184,7 @@ public sealed class EncounterCompletionOperationsTests
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
@ -196,6 +208,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
failingCompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -207,6 +220,7 @@ public sealed class EncounterCompletionOperationsTests
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
}
[Fact]
@ -226,6 +240,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
inventoryStore,
TimeProvider.System);
@ -237,6 +252,7 @@ public sealed class EncounterCompletionOperationsTests
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
}
[Fact]
@ -254,6 +270,7 @@ public sealed class EncounterCompletionOperationsTests
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
@ -261,6 +278,7 @@ public sealed class EncounterCompletionOperationsTests
// Assert
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode);
Assert.False(deps.EventStore.TryGet(PlayerId, "unknown_encounter", out _));
}
private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps)
@ -313,6 +331,7 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
}
@ -350,6 +369,7 @@ public sealed class EncounterCompletionOperationsTests
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore EventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);

View File

@ -212,6 +212,7 @@ public class EncounterDefinitionRegistryTests
_ = await client.GetAsync("/health");
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
var completeEventStore = factory.Services.GetRequiredService<IEncounterCompleteEventStore>();
// Act
var activated = progressStore.TryActivate("dev-local-1", PrototypeCombatPocket);
var completed = completionStore.TryMarkCompleted(
@ -222,5 +223,6 @@ public class EncounterDefinitionRegistryTests
Assert.True(activated);
Assert.True(completed);
Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket));
Assert.IsType<InMemoryEncounterCompleteEventStore>(completeEventStore);
}
}

View File

@ -0,0 +1,56 @@
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class InMemoryEncounterCompleteEventStoreTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private static readonly DateTimeOffset CompletedAt = new(2026, 5, 31, 20, 0, 0, TimeSpan.Zero);
[Fact]
public void TryRecord_ShouldBeIdempotent_AtStoreLayer()
{
// Arrange
var store = new InMemoryEncounterCompleteEventStore();
var completeEvent = CreatePrototypeEvent();
// Act
var first = store.TryRecord(completeEvent);
var second = store.TryRecord(completeEvent with { CompletedAt = CompletedAt.AddHours(1) });
var found = store.TryGet(PlayerId, EncounterId, out var stored);
// Assert
Assert.True(first);
Assert.False(second);
Assert.True(found);
Assert.Equal(completeEvent.IdempotencyKey, stored.IdempotencyKey);
Assert.Equal(CompletedAt, stored.CompletedAt);
Assert.Equal(2, stored.GrantedItems.Count);
}
[Fact]
public void TryRecord_ShouldFailClosed_WhenPlayerOrEncounterIdIsEmpty()
{
// Arrange
var store = new InMemoryEncounterCompleteEventStore();
var invalidEvent = CreatePrototypeEvent() with { PlayerId = " " };
// Act
var recorded = store.TryRecord(invalidEvent);
var found = store.TryGet(" ", EncounterId, out _);
// Assert
Assert.False(recorded);
Assert.False(found);
}
private static EncounterCompleteEvent CreatePrototypeEvent() =>
new(
PlayerId,
EncounterId,
"prototype_combat_pocket_clear",
[
new EncounterGrantApplied("scrap_metal_bulk", 10),
new EncounterGrantApplied("contract_handoff_token", 1),
],
CompletedAt,
$"{PlayerId}:{EncounterId}");
}

View File

@ -75,6 +75,7 @@ public static class AbilityCastApi
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore encounterProgressStore,
IEncounterCompletionStore encounterCompletionStore,
IEncounterCompleteEventStore encounterCompleteEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ILoggerFactory loggerFactory,
@ -273,6 +274,7 @@ public static class AbilityCastApi
rewardTableRegistry,
encounterProgressStore,
encounterCompletionStore,
encounterCompleteEventStore,
itemRegistry,
inventoryStore,
clock,

View File

@ -78,6 +78,7 @@ public static class EncounterCatalogServiceCollectionExtensions
services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();
services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();
services.AddSingleton<IEncounterCompleteEventStore, InMemoryEncounterCompleteEventStore>();
return services;
}

View File

@ -20,6 +20,7 @@ public static class EncounterCombatWiring
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider,
@ -75,6 +76,7 @@ public static class EncounterCombatWiring
rewardTableRegistry,
progressStore,
completionStore,
completeEventStore,
itemRegistry,
inventoryStore,
timeProvider);

View File

@ -2,7 +2,8 @@ namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Internal completion payload returned on successful encounter grant (NEO-105).
/// Persistence and E7.M2 quest-credit routing land in NEO-107.
/// Persisted via <see cref="IEncounterCompleteEventStore"/> on success (NEO-107).
/// E7.M2 quest-credit routing consumes this record (hook stub on NEO-107).
/// </summary>
public readonly record struct EncounterCompleteEvent(
string PlayerId,

View File

@ -21,6 +21,7 @@ public static class EncounterCompletionOperations
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider)
@ -107,9 +108,6 @@ public static class EncounterCompletionOperations
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent (NEO-107 expands persistence + hook).
// TODO(E9.M1): catalog emit — encounter_complete on successful grant commit (NEO-109).
var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId);
var completeEvent = new EncounterCompleteEvent(
normalizedPlayerId,
@ -119,6 +117,13 @@ public static class EncounterCompletionOperations
completedAt,
MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId));
_ = completeEventStore.TryRecord(completeEvent);
// TODO(E7.M2): reward_delivery — QuestRewardBundle router consumes EncounterCompleteEvent
// (playerId, encounterId, idempotencyKey, grantedItemSummary) → idempotent RewardDeliveryEvent.
// Prototype quest credit: contract_handoff_token item loot + this event record; full router: E7.M2.
// TODO(E9.M1): catalog emit — encounter_complete on successful grant commit (NEO-109).
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Idempotent per-player encounter completion event record (NEO-107).</summary>
public interface IEncounterCompleteEventStore
{
/// <summary>First record returns <c>true</c>; replays for the same player+encounter return <c>false</c>.</summary>
bool TryRecord(EncounterCompleteEvent completeEvent);
bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent);
}

View File

@ -0,0 +1,51 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Thread-safe in-memory encounter completion event records (NEO-107).</summary>
public sealed class InMemoryEncounterCompleteEventStore : IEncounterCompleteEventStore
{
private readonly ConcurrentDictionary<string, EncounterCompleteEvent> eventsByKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryRecord(EncounterCompleteEvent completeEvent)
{
var key = EncounterProgressIds.MakeProgressKey(completeEvent.PlayerId, completeEvent.EncounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (eventsByKey.ContainsKey(key))
{
return false;
}
eventsByKey[key] = completeEvent with
{
GrantedItems = completeEvent.GrantedItems.ToArray(),
};
return true;
}
}
/// <inheritdoc />
public bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent)
{
completeEvent = default;
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
return eventsByKey.TryGetValue(key, out completeEvent);
}
}
}

View File

@ -157,7 +157,7 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act
## Encounter completion + inventory grants (NEO-105)
**`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward tables **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. Returns internal **`EncounterCompleteEvent`** on success (persistence + E7.M2 hook: NEO-107).
**`EncounterCompletionOperations.TryCompleteAndGrant`** applies a reward tables **`fixedGrants`** via **`PlayerInventoryOperations`** when all required NPCs are defeated, then marks **`IEncounterCompletionStore`** idempotently. On success, records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** and returns the same payload in **`EncounterCompletionResult`**.
| Reason code | When |
|-------------|------|
@ -170,6 +170,22 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act
Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** from **`prototype_combat_pocket_clear`**. Inventory deny fails closed without marking complete (compensating rollback on partial apply or failed completion mark). Wired from **`AbilityCastApi`** via **`EncounterCombatWiring`** (NEO-106). Plan: [NEO-105 implementation plan](../../docs/plans/NEO-105-implementation-plan.md).
## Encounter complete event record (NEO-107)
**`IEncounterCompleteEventStore`** persists **`EncounterCompleteEvent`** exactly once per player+encounter when grants commit successfully. Prototype uses **`InMemoryEncounterCompleteEventStore`** (registered in **`AddEncounterAndRewardCatalogs`** alongside progress/completion stores).
| Field | Role |
|-------|------|
| **`PlayerId`**, **`EncounterId`** | Normalized keys (same as progress/completion stores). |
| **`RewardTableId`** | Catalog reward table applied. |
| **`GrantedItems`** | Snapshot of successful fixed grants at commit time. |
| **`CompletedAt`** | UTC timestamp from **`TimeProvider`** at completion mark. |
| **`IdempotencyKey`** | Stable **`{playerId}:{encounterId}`** — future **`RewardDeliveryEvent`** dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md). |
**E7.M2 hook (comment-only):** after **`TryRecord`**, **`EncounterCompletionOperations`** documents the future **`QuestRewardBundle`** / **`reward_delivery`** consumer — prototype quest credit is **`contract_handoff_token`** item loot plus this event record; full router deferred to E7.M2. If **`TryRecord`** returns `false` after a fresh completion mark (unexpected race), grants and completion flag are retained; treat as idempotent success.
Plan: [NEO-107 implementation plan](../../docs/plans/NEO-107-implementation-plan.md).
## Encounter combat wiring (NEO-106)
On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring.TryProcessCastOutcome`** (best-effort; does not change cast JSON):