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
parent
986cf55e73
commit
a02496c6bc
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ public static class EncounterCatalogServiceCollectionExtensions
|
|||
|
||||
services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();
|
||||
services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();
|
||||
services.AddSingleton<IEncounterCompleteEventStore, InMemoryEncounterCompleteEventStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue