diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs index 16fb3bb..deeda5d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); } @@ -349,6 +362,7 @@ public sealed class EncounterCombatWiringTests IRewardTableDefinitionRegistry RewardTableRegistry, IEncounterProgressStore ProgressStore, IEncounterCompletionStore CompletionStore, + IEncounterCompleteEventStore CompleteEventStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore); } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs index 70a847a..8d3183c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs @@ -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(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); } @@ -350,6 +369,7 @@ public sealed class EncounterCompletionOperationsTests IRewardTableDefinitionRegistry RewardTableRegistry, IEncounterProgressStore ProgressStore, IEncounterCompletionStore CompletionStore, + IEncounterCompleteEventStore EventStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore); diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs index 9af8db9..32a9add 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs @@ -212,6 +212,7 @@ public class EncounterDefinitionRegistryTests _ = await client.GetAsync("/health"); var progressStore = factory.Services.GetRequiredService(); var completionStore = factory.Services.GetRequiredService(); + var completeEventStore = factory.Services.GetRequiredService(); // 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(completeEventStore); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs new file mode 100644 index 0000000..476e480 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs @@ -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}"); +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 7d3f255..2765880 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs index 9c3fdaf..9a6fbe0 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs @@ -78,6 +78,7 @@ public static class EncounterCatalogServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs index e9575ac..3072c58 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs @@ -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); diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs index e3a18ac..3b4e238 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs @@ -2,7 +2,8 @@ namespace NeonSprawl.Server.Game.Encounters; /// /// Internal completion payload returned on successful encounter grant (NEO-105). -/// Persistence and E7.M2 quest-credit routing land in NEO-107. +/// Persisted via on success (NEO-107). +/// E7.M2 quest-credit routing consumes this record (hook stub on NEO-107). /// public readonly record struct EncounterCompleteEvent( string PlayerId, diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs index 01ed842..7bb2136 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs b/server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs new file mode 100644 index 0000000..a7f8b27 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs @@ -0,0 +1,10 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Idempotent per-player encounter completion event record (NEO-107). +public interface IEncounterCompleteEventStore +{ + /// First record returns true; replays for the same player+encounter return false. + bool TryRecord(EncounterCompleteEvent completeEvent); + + bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs new file mode 100644 index 0000000..bd7236d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs @@ -0,0 +1,51 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Thread-safe in-memory encounter completion event records (NEO-107). +public sealed class InMemoryEncounterCompleteEventStore : IEncounterCompleteEventStore +{ + private readonly ConcurrentDictionary eventsByKey = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary keyLocks = new(StringComparer.Ordinal); + + /// + 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; + } + } + + /// + 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); + } + } +} diff --git a/server/README.md b/server/README.md index 4a05c76..0f3d515 100644 --- a/server/README.md +++ b/server/README.md @@ -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 table’s **`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 table’s **`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):