NEO-104: Address code review findings for encounter stores.

Normalize encounter ids in IsAllRequiredTargetsDefeated, document TryActivate
completion gating, lock IsCompleted reads, add operations tests, and update
plan and review doc.
pull/143/head
VinPropane 2026-05-31 16:37:22 -04:00
parent 87dff6e3f4
commit 91f74b7d6e
7 changed files with 84 additions and 12 deletions

View File

@ -62,6 +62,7 @@
- **DI:** registered in `AddEncounterAndRewardCatalogs`. - **DI:** registered in `AddEncounterAndRewardCatalogs`.
- **Tests:** `InMemoryEncounterProgressStoreTests`, `InMemoryEncounterCompletionStoreTests`, `EncounterProgressOperationsTests`; host DI resolve in `EncounterDefinitionRegistryTests`. - **Tests:** `InMemoryEncounterProgressStoreTests`, `InMemoryEncounterCompletionStoreTests`, `EncounterProgressOperationsTests`; host DI resolve in `EncounterDefinitionRegistryTests`.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated. - **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
- **Plan file list:** `Program.cs` and `InMemoryWebApplicationFactory.cs` unchanged — stores register in existing `AddEncounterAndRewardCatalogs` (already invoked at startup); always in-memory (no Postgres test-factory swap).
## Technical approach ## Technical approach

View File

@ -30,19 +30,19 @@ None.
## Suggestions ## Suggestions
1. **Plan file list vs shipped diff** — The plan lists **`InMemoryWebApplicationFactory.cs`** and **`Program.cs`** as files to modify; neither changed. This is acceptable: stores register in the existing **`AddEncounterAndRewardCatalogs`** extension (already invoked at startup), and encounter stores are always in-memory (no Postgres swap needed in the test factory, unlike position/inventory stores). Consider a one-line note in the plan **Implementation reconciliation** that factory/Program edits were N/A. 1. ~~**Plan file list vs shipped diff** — The plan lists **`InMemoryWebApplicationFactory.cs`** and **`Program.cs`** as files to modify; neither changed. This is acceptable: stores register in the existing **`AddEncounterAndRewardCatalogs`** extension (already invoked at startup), and encounter stores are always in-memory (no Postgres swap needed in the test factory, unlike position/inventory stores). Consider a one-line note in the plan **Implementation reconciliation** that factory/Program edits were N/A.~~ **Done.** Note added to plan **Implementation reconciliation**.
2. **Completion guard on `TryActivate`** — The plan describes **`TryActivate`** returning `false` when the completion store marks the encounter done. Gating lives only in **`EncounterProgressOperations.TryActivateOnFirstEngagement`**; a direct **`IEncounterProgressStore.TryActivate`** call after completion would still create a started row. Acceptable while NEO-106/105 route exclusively through operations; add a brief XML note on **`IEncounterProgressStore.TryActivate`** that completion gating is the caller's responsibility if direct store use is anticipated. 2. ~~**Completion guard on `TryActivate`** — The plan describes **`TryActivate`** returning `false` when the completion store marks the encounter done. Gating lives only in **`EncounterProgressOperations.TryActivateOnFirstEngagement`**; a direct **`IEncounterProgressStore.TryActivate`** call after completion would still create a started row. Acceptable while NEO-106/105 route exclusively through operations; add a brief XML note on **`IEncounterProgressStore.TryActivate`** that completion gating is the caller's responsibility if direct store use is anticipated.~~ **Done.** XML on **`IEncounterProgressStore.TryActivate`**.
3. **Optional operations coverage** — Store and core happy-path operations are well covered. Consider adding AAA tests for: **`TryActivateOnFirstEngagement`** returns `false` when encounter is already completed; resolve/activate returns `false` for an NPC id not in any encounter definition. 3. ~~**Optional operations coverage** — Store and core happy-path operations are well covered. Consider adding AAA tests for: **`TryActivateOnFirstEngagement`** returns `false` when encounter is already completed; resolve/activate returns `false` for an NPC id not in any encounter definition.~~ **Done.** Two new tests in **`EncounterProgressOperationsTests`** plus mixed-case **`IsAllRequiredTargetsDefeated`** coverage.
4. **`IsAllRequiredTargetsDefeated` id normalization** — The method passes **`encounterId`** to case-sensitive **`TryGetDefinition`**. NEO-102 precedent documents that only **`TryNormalizeKnown`** lowercases. Internal callers today use catalog **`row.Id`** (lowercase); NEO-108 HTTP should normalize before calling — optionally harden now with **`TryNormalizeKnown`** or **`EncounterProgressIds.NormalizeEncounterId`** + lookup for defense in depth. 4. ~~**`IsAllRequiredTargetsDefeated` id normalization** — The method passes **`encounterId`** to case-sensitive **`TryGetDefinition`**. NEO-102 precedent documents that only **`TryNormalizeKnown`** lowercases. Internal callers today use catalog **`row.Id`** (lowercase); NEO-108 HTTP should normalize before calling — optionally harden now with **`TryNormalizeKnown`** or **`EncounterProgressIds.NormalizeEncounterId`** + lookup for defense in depth.~~ **Done.** **`TryNormalizeKnown`** before lookup and progress read.
## Nits ## Nits
- Nit: **`Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds`** — **`Assert.NotNull`** on values from **`GetRequiredService`** is redundant (missing registration throws). Minor AAA nit: DI resolution fits **Arrange**; **Act** could be only **`TryActivate`** / **`TryMarkCompleted`**. - ~~Nit: **`Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds`** — **`Assert.NotNull`** on values from **`GetRequiredService`** is redundant (missing registration throws). Minor AAA nit: DI resolution fits **Arrange**; **Act** could be only **`TryActivate`** / **`TryMarkCompleted`**.~~ **Done.**
- Nit: **`InMemoryEncounterCompletionStore.IsCompleted`** reads **`ConcurrentDictionary`** without the per-key lock used by **`TryMarkCompleted`** / **`TryGetCompletedAt`**. Benign for prototype bool checks; align with lock if strict read-your-writes under concurrency matters later. - ~~Nit: **`InMemoryEncounterCompletionStore.IsCompleted`** reads **`ConcurrentDictionary`** without the per-key lock used by **`TryMarkCompleted`** / **`TryGetCompletedAt`**. Benign for prototype bool checks; align with lock if strict read-your-writes under concurrency matters later.~~ **Done.** **`IsCompleted`** uses per-key lock.
## Verification ## Verification

View File

@ -210,17 +210,15 @@ public class EncounterDefinitionRegistryTests
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient(); using var client = factory.CreateClient();
_ = await client.GetAsync("/health"); _ = await client.GetAsync("/health");
// Act
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>(); var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>(); var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
// Act
var activated = progressStore.TryActivate("dev-local-1", PrototypeCombatPocket); var activated = progressStore.TryActivate("dev-local-1", PrototypeCombatPocket);
var completed = completionStore.TryMarkCompleted( var completed = completionStore.TryMarkCompleted(
"dev-local-1", "dev-local-1",
PrototypeCombatPocket, PrototypeCombatPocket,
new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero)); new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero));
// Assert // Assert
Assert.NotNull(progressStore);
Assert.NotNull(completionStore);
Assert.True(activated); Assert.True(activated);
Assert.True(completed); Assert.True(completed);
Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket)); Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket));

View File

@ -118,6 +118,68 @@ public sealed class EncounterProgressOperationsTests
Assert.False(marked); Assert.False(marked);
} }
[Fact]
public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenEncounterAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
_ = completion.TryMarkCompleted(PlayerId, EncounterId, DateTimeOffset.UtcNow);
// Act
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
registry,
progress,
completion);
// Assert
Assert.False(activated);
Assert.False(progress.TryGetProgress(PlayerId, EncounterId, out _));
}
[Fact]
public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenNpcNotInAnyEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
// Act
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
"prototype_npc_unknown",
registry,
progress,
completion);
// Assert
Assert.False(activated);
}
[Fact]
public async Task IsAllRequiredTargetsDefeated_ShouldReturnTrue_WhenEncounterIdHasMixedCase()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, RangedNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, EliteNpc, registry, progress, completion);
// Act
var allDefeated = EncounterProgressOperations.IsAllRequiredTargetsDefeated(
PlayerId,
" Prototype_Combat_Pocket ",
registry,
progress);
// Assert
Assert.True(allDefeated);
}
[Fact] [Fact]
public async Task TryMarkCompleted_ShouldReturnFalseOnSecondAttempt_WhenUsingCompletionStoreDirectly() public async Task TryMarkCompleted_ShouldReturnFalseOnSecondAttempt_WhenUsingCompletionStoreDirectly()
{ {

View File

@ -116,12 +116,13 @@ public static class EncounterProgressOperations
IEncounterDefinitionRegistry encounterRegistry, IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore progressStore) IEncounterProgressStore progressStore)
{ {
if (!encounterRegistry.TryGetDefinition(encounterId, out var definition)) if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var definition))
{ {
return false; return false;
} }
if (!progressStore.TryGetProgress(playerId, encounterId, out var progress) || !progress.Started) if (!progressStore.TryGetProgress(playerId, normalizedEncounterId, out var progress) || !progress.Started)
{ {
return false; return false;
} }

View File

@ -9,6 +9,8 @@ public interface IEncounterProgressStore
/// <summary> /// <summary>
/// Creates a started row with an empty defeated set. /// Creates a started row with an empty defeated set.
/// Returns <c>true</c> on first activation; <c>false</c> when already started. /// Returns <c>true</c> on first activation; <c>false</c> when already started.
/// Does not consult <see cref="IEncounterCompletionStore"/> — callers that must block activation after completion
/// (e.g. <see cref="EncounterProgressOperations.TryActivateOnFirstEngagement"/>) must gate before calling this method.
/// </summary> /// </summary>
bool TryActivate(string playerId, string encounterId); bool TryActivate(string playerId, string encounterId);

View File

@ -13,7 +13,15 @@ public sealed class InMemoryEncounterCompletionStore : IEncounterCompletionStore
public bool IsCompleted(string playerId, string encounterId) public bool IsCompleted(string playerId, string encounterId)
{ {
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId); var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
return key.Length > 0 && completedAtByKey.ContainsKey(key); if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
return completedAtByKey.ContainsKey(key);
}
} }
/// <inheritdoc /> /// <inheritdoc />