NEO-126: add IRewardDeliveryStore for quest completion rewards

In-memory RewardDeliveryEvent store with idempotent TryRecord,
grant summaries for future HTTP projection, DI registration, and tests.
pull/165/head
VinPropane 2026-06-07 17:31:45 -04:00
parent b20d9c18b5
commit 2a8fe31b14
14 changed files with 321 additions and 5 deletions

View File

@ -0,0 +1,25 @@
meta {
name: GET health (reward delivery store NEO-126)
type: http
seq: 2
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-126 registers IRewardDeliveryStore (in-memory prototype). No quest reward delivery HTTP API in this story — use this request to confirm the host started after store DI wiring.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

File diff suppressed because one or more lines are too long

View File

@ -146,8 +146,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria**
- [ ] `TryRecord` idempotent on same key.
- [ ] Store readable summary for HTTP projection.
- [x] `TryRecord` idempotent on same key.
- [x] Store readable summary for HTTP projection.
**Client counterpart:** none (infrastructure-only).

View File

@ -50,8 +50,17 @@
## Acceptance criteria checklist
- [ ] `TryRecord` idempotent on same key.
- [ ] Store readable summary for HTTP projection.
- [x] `TryRecord` idempotent on same key.
- [x] Store readable summary for HTTP projection.
## Implementation reconciliation (shipped)
- **Types:** `RewardDeliveryEvent`, `RewardItemGrantApplied`, `RewardSkillXpGrantApplied`, `RewardDeliveryIds` in `Game/Rewards/`.
- **Store:** `IRewardDeliveryStore` + `InMemoryRewardDeliveryStore` (`TryRecord`, `TryGet`, dev `TryClear`).
- **DI:** `AddRewardDeliveryStore()` in `Program.cs` — in-memory singleton only (Postgres deferred per kickoff).
- **Tests:** 5 AAA tests in `InMemoryRewardDeliveryStoreTests` (idempotency, fail-closed, grant summary, host DI resolve).
- **Docs:** `server/README.md` reward delivery store section; E7M2-03 backlog AC checked; alignment register updated.
- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru` (host boot smoke; pre-commit gate for `Program.cs`).
## Technical approach

View File

@ -0,0 +1,103 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Rewards;
public sealed class InMemoryRewardDeliveryStoreTests
{
private const string PlayerId = "dev-local-1";
private const string QuestId = "prototype_quest_operator_chain";
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void TryRecord_ShouldBeIdempotent_AtStoreLayer()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var deliveryEvent = CreatePrototypeEvent();
// Act
var first = store.TryRecord(deliveryEvent);
var second = store.TryRecord(deliveryEvent with { DeliveredAt = DeliveredAt.AddHours(1) });
// Assert
Assert.True(first);
Assert.False(second);
Assert.True(store.TryGet(PlayerId, QuestId, out var stored));
Assert.Equal(deliveryEvent.IdempotencyKey, stored.IdempotencyKey);
Assert.Equal(DeliveredAt, stored.DeliveredAt);
Assert.Single(stored.GrantedItems);
Assert.Single(stored.GrantedSkillXp);
}
[Fact]
public void TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var invalidEvent = CreatePrototypeEvent() with { PlayerId = " " };
// Act
var recorded = store.TryRecord(invalidEvent);
// Assert
Assert.False(recorded);
Assert.False(store.TryGet(" ", QuestId, out _));
}
[Fact]
public void TryRecord_ShouldFailClosed_WhenQuestIdIsEmpty()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var invalidEvent = CreatePrototypeEvent() with { QuestId = " " };
// Act
var recorded = store.TryRecord(invalidEvent);
// Assert
Assert.False(recorded);
Assert.False(store.TryGet(PlayerId, " ", out _));
}
[Fact]
public void TryGet_ShouldExposeReadableGrantSummary()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
var deliveryEvent = CreatePrototypeEvent();
// Act
var recorded = store.TryRecord(deliveryEvent);
var found = store.TryGet(PlayerId, QuestId, out var stored);
// Assert
Assert.True(recorded);
Assert.True(found);
Assert.Equal("survey_drone_kit", stored.GrantedItems[0].ItemId);
Assert.Equal(1, stored.GrantedItems[0].Quantity);
Assert.Equal("salvage", stored.GrantedSkillXp[0].SkillId);
Assert.Equal(50, stored.GrantedSkillXp[0].Amount);
}
[Fact]
public async Task Host_ShouldResolveRewardDeliveryStoreFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var deliveryStore = factory.Services.GetRequiredService<IRewardDeliveryStore>();
// Act
var recorded = deliveryStore.TryRecord(CreatePrototypeEvent());
var found = deliveryStore.TryGet(PlayerId, QuestId, out var stored);
// Assert
Assert.IsType<InMemoryRewardDeliveryStore>(deliveryStore);
Assert.True(recorded);
Assert.True(found);
Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey);
}
private static RewardDeliveryEvent CreatePrototypeEvent() =>
new(
PlayerId,
QuestId,
DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)],
[new RewardSkillXpGrantApplied("salvage", 50)],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId));
}

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Idempotent per-player quest completion reward delivery record (NEO-126).</summary>
public interface IRewardDeliveryStore
{
/// <summary>First record returns <c>true</c>; replays for the same player+quest return <c>false</c>.</summary>
bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
}

View File

@ -0,0 +1,69 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Thread-safe in-memory quest completion reward delivery records (NEO-126).</summary>
public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
{
private readonly ConcurrentDictionary<string, RewardDeliveryEvent> eventsByKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
{
var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.QuestId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (eventsByKey.ContainsKey(key))
{
return false;
}
eventsByKey[key] = deliveryEvent with
{
GrantedItems = deliveryEvent.GrantedItems.ToArray(),
GrantedSkillXp = deliveryEvent.GrantedSkillXp.ToArray(),
};
return true;
}
}
/// <inheritdoc />
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
{
deliveryEvent = default;
var key = RewardDeliveryIds.MakeStoreKey(playerId, questId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
return eventsByKey.TryGetValue(key, out deliveryEvent);
}
}
/// <summary>Dev fixture only — removes one player+quest delivery row (Bruno / test reset).</summary>
public bool TryClear(string playerId, string questId)
{
var key = RewardDeliveryIds.MakeStoreKey(playerId, questId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var removed = eventsByKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _);
return removed;
}
}
}

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>
/// Idempotent quest completion reward delivery record (NEO-126).
/// Persisted via <see cref="IRewardDeliveryStore"/> after router apply succeeds (NEO-127).
/// </summary>
public readonly record struct RewardDeliveryEvent(
string PlayerId,
string QuestId,
DateTimeOffset DeliveredAt,
IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
string IdempotencyKey);

View File

@ -0,0 +1,46 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Id normalization and composite keys for reward delivery stores (NEO-126).</summary>
public static class RewardDeliveryIds
{
/// <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 NormalizeQuestId(string? questId) => NormalizePlayerId(questId);
/// <summary>Stable idempotency key for quest completion delivery.</summary>
public static string MakeIdempotencyKey(string? playerId, string? questId)
{
var p = NormalizePlayerId(playerId);
var q = NormalizeQuestId(questId);
if (p.Length == 0 || q.Length == 0)
{
return string.Empty;
}
return $"{p}:quest_complete:{q}";
}
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary>
public static string MakeStoreKey(string? playerId, string? questId)
{
var p = NormalizePlayerId(playerId);
var q = NormalizeQuestId(questId);
if (p.Length == 0 || q.Length == 0)
{
return string.Empty;
}
return $"{p}\0{q}";
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Registers quest completion reward delivery persistence (NEO-126).</summary>
public static class RewardDeliveryServiceCollectionExtensions
{
/// <summary>Prototype uses in-memory singleton; Postgres deferred until a persistence story lands.</summary>
public static IServiceCollection AddRewardDeliveryStore(this IServiceCollection services)
{
services.AddSingleton<IRewardDeliveryStore, InMemoryRewardDeliveryStore>();
return services;
}
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>One item grant applied on a successful quest completion reward delivery (NEO-126).</summary>
public readonly record struct RewardItemGrantApplied(string ItemId, int Quantity);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>One skill XP grant applied on a successful quest completion reward delivery (NEO-126).</summary>
public readonly record struct RewardSkillXpGrantApplied(string SkillId, int Amount);

View File

@ -10,6 +10,7 @@ using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting;
@ -30,6 +31,7 @@ builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddPlayerQuestStateStore(builder.Configuration);
builder.Services.AddRewardDeliveryStore();
builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore();

View File

@ -265,6 +265,25 @@ Comment-only hook sites in **`QuestStateOperations`** for future E9.M1 catalog e
Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan: [NEO-121 implementation plan](../../docs/plans/NEO-121-implementation-plan.md).
## Reward delivery store (NEO-126)
**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully (router apply lands in NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**).
| Field | Role |
|-------|------|
| **`PlayerId`**, **`QuestId`** | Normalized keys (trim + lowercase). |
| **`DeliveredAt`** | UTC timestamp at delivery record commit. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) at commit time. |
| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. |
**Store interface:**
- **`TryRecord`** — first delivery returns `true`; replays for the same player+quest return `false` without overwriting the snapshot.
- **`TryGet`** — read one delivery event for HTTP **`completionRewardSummary`** projection (NEO-129).
Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest completion rewards use this store only. Postgres persistence deferred. Plan: [NEO-126 implementation plan](../../docs/plans/NEO-126-implementation-plan.md).
## Encounter definitions (NEO-103)
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.