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
parent
b20d9c18b5
commit
2a8fe31b14
|
|
@ -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
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue