NEO-148: add contract completion reward router and delivery store extension

TryDeliverContractCompletion reuses the quest grant-apply path with
contract-specific idempotency keys and source-kind-aware delivery store rows.
pull/189/head
VinPropane 2026-06-27 19:58:07 -04:00
parent b20d7d001e
commit 76ce1943c7
13 changed files with 464 additions and 56 deletions

View File

@ -59,6 +59,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147). **Generator operations (NEO-147):** **`ContractGeneratorOperations.TryIssue`** — template selection, deterministic instance ids, structured deny codes; [server README — Contract generator operations](../../../server/README.md#contract-generator-operations-neo-147).
**Contract reward router (NEO-148):** **`RewardRouterOperations.TryDeliverContractCompletion`** — idempotent contract **`completionRewardBundle`** apply with key **`{playerId}:contract_complete:{contractInstanceId}`**; [server README — Reward router](../../../server/README.md#reward-router-neo-127).
## Source anchors ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

View File

@ -55,8 +55,16 @@ Contract completion payouts reuse E7.M2 delivery idempotency with contract-speci
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] First-time contract complete applies bundle exactly once per instance id. - [x] First-time contract complete applies bundle exactly once per instance id.
- [ ] Replay returns **`already_delivered`** without double grant. - [x] Replay returns **`already_delivered`** without double grant.
## Implementation reconciliation (shipped)
- **Types:** `RewardDeliverySourceKinds`; `RewardDeliveryEvent.SourceKind` + `ContractInstanceId`; `RewardDeliveryReasonCodes.InvalidContractInstanceId`; `ReputationDeltaSourceKinds.ContractCompletion`.
- **Store:** `IRewardDeliveryStore` source-kind-aware `TryGet`/`TryClear`; `InMemoryRewardDeliveryStore` keys `{playerId}\0{sourceKind}\0{sourceId}`.
- **Router:** `TryDeliverContractCompletion` + private shared `TryDeliverBundle` core; quest path unchanged at public API.
- **Tests:** 5 contract router tests + 1 store isolation test; full suite **897** tests green.
- **Docs:** `server/README.md` delivery store + router sections; E7.M4 module anchor.
## Technical approach ## Technical approach

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -124,6 +125,49 @@ public sealed class InMemoryRewardDeliveryStoreTests
Assert.True(cleared); Assert.True(cleared);
} }
[Fact]
public void TryRecord_ShouldIsolateContractRows_FromQuestRowsWithSameSourceId()
{
// Arrange
var store = new InMemoryRewardDeliveryStore();
const string sharedSourceId = "prototype_shared_source_id";
var questEvent = new RewardDeliveryEvent(
PlayerId,
sharedSourceId,
DeliveredAt,
[new RewardItemGrantApplied("survey_drone_kit", 1)],
[],
[],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, sharedSourceId));
var contractEvent = new RewardDeliveryEvent(
PlayerId,
QuestId: string.Empty,
DeliveredAt,
[new RewardItemGrantApplied("scrap_metal_bulk", 5)],
[],
[],
ContractOutcomeIds.MakeIdempotencyKey(PlayerId, sharedSourceId),
RewardDeliverySourceKinds.ContractCompletion,
sharedSourceId);
// Act
var questRecorded = store.TryRecord(questEvent);
var contractRecorded = store.TryRecord(contractEvent);
// Assert
Assert.True(questRecorded);
Assert.True(contractRecorded);
Assert.True(store.TryGet(PlayerId, sharedSourceId, out var storedQuest));
Assert.True(
store.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
sharedSourceId,
out var storedContract));
Assert.Equal(RewardDeliverySourceKinds.QuestCompletion, storedQuest.SourceKind);
Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, storedContract.SourceKind);
Assert.Equal("survey_drone_kit", storedQuest.GrantedItems[0].ItemId);
Assert.Equal("scrap_metal_bulk", storedContract.GrantedItems[0].ItemId);
}
private static RewardDeliveryEvent CreatePrototypeEvent() => private static RewardDeliveryEvent CreatePrototypeEvent() =>
new( new(
PlayerId, PlayerId,

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
@ -18,6 +19,7 @@ public sealed class RewardRouterOperationsTests
private const string OperatorChainQuestId = "prototype_quest_operator_chain"; private const string OperatorChainQuestId = "prototype_quest_operator_chain";
private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
private const string RustCollectiveFactionId = "prototype_faction_rust_collective"; private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
private const string ContractInstanceId = "ci_test_contract_completion_001";
private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1"; private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1";
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero);
@ -653,6 +655,181 @@ public sealed class RewardRouterOperationsTests
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
} }
[Fact]
public async Task TryDeliverContractCompletion_ShouldApplyPrototypeBundle_WhenFirstDelivery()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = PrototypeContractCompletionBundle();
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
var scrapBefore = CountBagItem(inventoryBefore!, "scrap_metal_bulk");
// Act
var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Single(result.GrantedItems);
Assert.Equal("scrap_metal_bulk", result.GrantedItems[0].ItemId);
Assert.Equal(5, result.GrantedItems[0].Quantity);
Assert.Single(result.GrantedSkillXp);
Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId);
Assert.Equal(15, result.GrantedSkillXp[0].Amount);
Assert.Empty(result.GrantedReputation);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
ContractInstanceId,
out var stored));
Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, stored.SourceKind);
Assert.Equal(ContractInstanceId, stored.ContractInstanceId);
Assert.Equal(
ContractOutcomeIds.MakeIdempotencyKey(PlayerId, ContractInstanceId),
stored.IdempotencyKey);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
Assert.Equal(scrapBefore + 5, CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
Assert.Equal(salvageBefore + 15, salvageXp);
}
[Fact]
public async Task TryDeliverContractCompletion_ShouldReturnAlreadyDelivered_WhenReplayed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = PrototypeContractCompletionBundle();
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var scrapBefore = CountBagItem(inventoryBefore!, "scrap_metal_bulk");
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
// Act
var first = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
var second = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
// Assert
Assert.True(first.Success);
Assert.Null(first.ReasonCode);
Assert.True(second.Success);
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
Assert.Equal(scrapBefore + 5, CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
Assert.Equal(salvageBefore + 15, salvageAfter);
}
[Fact]
public async Task TryDeliverContractCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow(
[],
[],
[new ReputationGrantRow("prototype_faction_unknown", 10)]);
// Act
var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
Assert.False(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
ContractInstanceId,
out _));
}
[Fact]
public async Task TryDeliverContractCompletion_ShouldDenyInventoryFull_WithoutStoreRecord()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
FillBag(deps);
var bundle = PrototypeContractCompletionBundle();
// Act
var result = DeliverContract(deps, ContractInstanceId, bundle, TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
ContractInstanceId,
out _));
}
[Fact]
public async Task TryDeliverContractCompletion_ShouldDenyInvalidContractInstanceId_WhenEmpty()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = PrototypeContractCompletionBundle();
// Act
var result = RewardRouterOperations.TryDeliverContractCompletion(
PlayerId,
" ",
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InvalidContractInstanceId, result.ReasonCode);
}
private static QuestRewardBundleRow PrototypeContractCompletionBundle() =>
new(
[new RewardGrantRow("scrap_metal_bulk", 5)],
[new QuestSkillXpGrantRow("salvage", 15)]);
private static RewardDeliveryResult DeliverContract(
RewardRouterTestDependencies deps,
string contractInstanceId,
QuestRewardBundleRow? bundle,
TimeProvider timeProvider) =>
RewardRouterOperations.TryDeliverContractCompletion(
PlayerId,
contractInstanceId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
timeProvider);
private static RewardDeliveryResult Deliver( private static RewardDeliveryResult Deliver(
RewardRouterTestDependencies deps, RewardRouterTestDependencies deps,
string questId, string questId,
@ -755,7 +932,10 @@ public sealed class RewardRouterOperationsTests
return false; return false;
} }
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) =>
TryGet(playerId, RewardDeliverySourceKinds.QuestCompletion, questId, out deliveryEvent);
public bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent)
{ {
if (!recordAttempted) if (!recordAttempted)
{ {
@ -768,6 +948,8 @@ public sealed class RewardRouterOperationsTests
} }
public bool TryClear(string playerId, string questId) => true; public bool TryClear(string playerId, string questId) => true;
public bool TryClear(string playerId, string sourceKind, string sourceId) => true;
} }
/// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary> /// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary>

View File

@ -4,4 +4,6 @@ namespace NeonSprawl.Server.Game.Factions;
public static class ReputationDeltaSourceKinds public static class ReputationDeltaSourceKinds
{ {
public const string QuestCompletion = "quest_completion"; public const string QuestCompletion = "quest_completion";
public const string ContractCompletion = "contract_completion";
} }

View File

@ -1,16 +1,20 @@
namespace NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Idempotent per-player quest completion reward delivery record (NEO-126).</summary> /// <summary>Idempotent per-player reward delivery records (NEO-126).</summary>
public interface IRewardDeliveryStore public interface IRewardDeliveryStore
{ {
/// <summary>First record returns <c>true</c>; replays for the same player+quest return <c>false</c>.</summary> /// <summary>First record returns <c>true</c>; replays for the same player+source return <c>false</c>.</summary>
bool TryRecord(RewardDeliveryEvent deliveryEvent); bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent);
/// <summary> /// <summary>
/// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset. /// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset.
/// Idempotent when the row is already absent. /// Idempotent when the row is already absent.
/// </summary> /// </summary>
bool TryClear(string playerId, string questId); bool TryClear(string playerId, string questId);
bool TryClear(string playerId, string sourceKind, string sourceId);
} }

View File

@ -2,7 +2,7 @@ using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Thread-safe in-memory quest completion reward delivery records (NEO-126).</summary> /// <summary>Thread-safe in-memory reward delivery records (NEO-126).</summary>
public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
{ {
private readonly ConcurrentDictionary<string, RewardDeliveryEvent> eventsByKey = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, RewardDeliveryEvent> eventsByKey = new(StringComparer.Ordinal);
@ -12,7 +12,7 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
/// <inheritdoc /> /// <inheritdoc />
public bool TryRecord(RewardDeliveryEvent deliveryEvent) public bool TryRecord(RewardDeliveryEvent deliveryEvent)
{ {
var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.QuestId); var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent);
if (key.Length == 0) if (key.Length == 0)
{ {
return false; return false;
@ -36,10 +36,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
} }
/// <inheritdoc /> /// <inheritdoc />
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent) =>
TryGet(playerId, RewardDeliverySourceKinds.QuestCompletion, questId, out deliveryEvent);
/// <inheritdoc />
public bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent)
{ {
deliveryEvent = default; deliveryEvent = default;
var key = RewardDeliveryIds.MakeStoreKey(playerId, questId); var key = RewardDeliveryIds.MakeStoreKey(playerId, sourceKind, sourceId);
if (key.Length == 0) if (key.Length == 0)
{ {
return false; return false;
@ -51,10 +55,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
} }
} }
/// <summary>Dev fixture only — removes one player+quest delivery row (Bruno / test reset).</summary> /// <inheritdoc />
public bool TryClear(string playerId, string questId) public bool TryClear(string playerId, string questId) =>
TryClear(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
/// <summary>Dev fixture only — removes one player+source delivery row (Bruno / test reset).</summary>
public bool TryClear(string playerId, string sourceKind, string sourceId)
{ {
var key = RewardDeliveryIds.MakeStoreKey(playerId, questId); var key = RewardDeliveryIds.MakeStoreKey(playerId, sourceKind, sourceId);
if (key.Length == 0) if (key.Length == 0)
{ {
return false; return false;

View File

@ -1,9 +1,9 @@
namespace NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Rewards;
/// <summary> /// <summary>
/// Idempotent quest completion reward delivery record (NEO-126). /// Idempotent reward delivery record (NEO-126).
/// Persisted via <see cref="IRewardDeliveryStore"/> after router apply succeeds (NEO-127). /// Persisted via <see cref="IRewardDeliveryStore"/> after router apply succeeds (NEO-127).
/// E9.M1 <c>reward_delivery</c> hook site: <see cref="RewardRouterOperations"/> <c>TryDeliverQuestCompletion</c> (NEO-130). /// E9.M1 <c>reward_delivery</c> hook site: <see cref="RewardRouterOperations"/> (NEO-130).
/// </summary> /// </summary>
public readonly record struct RewardDeliveryEvent( public readonly record struct RewardDeliveryEvent(
string PlayerId, string PlayerId,
@ -12,4 +12,6 @@ public readonly record struct RewardDeliveryEvent(
IReadOnlyList<RewardItemGrantApplied> GrantedItems, IReadOnlyList<RewardItemGrantApplied> GrantedItems,
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp, IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
IReadOnlyList<RewardReputationGrantApplied> GrantedReputation, IReadOnlyList<RewardReputationGrantApplied> GrantedReputation,
string IdempotencyKey); string IdempotencyKey,
string SourceKind = RewardDeliverySourceKinds.QuestCompletion,
string ContractInstanceId = "");

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Rewards;
@ -20,6 +21,10 @@ public static class RewardDeliveryIds
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary> /// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId); public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId);
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeContractInstanceId(string? contractInstanceId) =>
ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
/// <summary>Stable idempotency key for quest completion delivery.</summary> /// <summary>Stable idempotency key for quest completion delivery.</summary>
public static string MakeIdempotencyKey(string? playerId, string? questId) public static string MakeIdempotencyKey(string? playerId, string? questId)
{ {
@ -49,16 +54,47 @@ public static class RewardDeliveryIds
public static string MakeReputationRollbackDeltaId(string reputationDeltaId) => public static string MakeReputationRollbackDeltaId(string reputationDeltaId) =>
reputationDeltaId.Length == 0 ? string.Empty : $"{reputationDeltaId}:rollback"; reputationDeltaId.Length == 0 ? string.Empty : $"{reputationDeltaId}:rollback";
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="questId"/>.</summary> /// <summary>Composite store key for quest completion delivery.</summary>
public static string MakeStoreKey(string? playerId, string? questId) public static string MakeStoreKey(string? playerId, string? questId) =>
MakeStoreKey(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="sourceKind"/> + source id.</summary>
public static string MakeStoreKey(string? playerId, string? sourceKind, string? sourceId)
{ {
var p = NormalizePlayerId(playerId); var p = NormalizePlayerId(playerId);
var q = NormalizeQuestId(questId); var kind = NormalizeSourceKind(sourceKind);
if (p.Length == 0 || q.Length == 0) var id = NormalizeSourceId(sourceKind, sourceId);
if (p.Length == 0 || kind.Length == 0 || id.Length == 0)
{ {
return string.Empty; return string.Empty;
} }
return $"{p}\0{q}"; return $"{p}\0{kind}\0{id}";
} }
/// <summary>Resolves the source id field for a persisted delivery event.</summary>
public static string GetSourceId(in RewardDeliveryEvent deliveryEvent) =>
string.Equals(deliveryEvent.SourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.Ordinal)
? NormalizeContractInstanceId(deliveryEvent.ContractInstanceId)
: NormalizeQuestId(deliveryEvent.QuestId);
/// <summary>Composite store key for a persisted delivery event.</summary>
public static string MakeStoreKey(in RewardDeliveryEvent deliveryEvent) =>
MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.SourceKind, GetSourceId(deliveryEvent));
private static string NormalizeSourceKind(string? sourceKind)
{
var trimmed = sourceKind?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return string.Empty;
}
return trimmed.ToLowerInvariant();
}
private static string NormalizeSourceId(string? sourceKind, string? sourceId) =>
string.Equals(sourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.OrdinalIgnoreCase)
? NormalizeContractInstanceId(sourceId)
: NormalizeQuestId(sourceId);
} }

View File

@ -14,6 +14,8 @@ public static class RewardDeliveryReasonCodes
public const string InvalidQuestId = "invalid_quest_id"; public const string InvalidQuestId = "invalid_quest_id";
public const string InvalidContractInstanceId = "invalid_contract_instance_id";
public const string InventoryStoreMissing = "inventory_store_missing"; public const string InventoryStoreMissing = "inventory_store_missing";
public const string ProgressionStoreMissing = "progression_store_missing"; public const string ProgressionStoreMissing = "progression_store_missing";

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Rewards;
/// <summary>Stable source-kind values for <see cref="RewardDeliveryEvent"/> (NEO-148).</summary>
public static class RewardDeliverySourceKinds
{
public const string QuestCompletion = "quest_completion";
public const string ContractCompletion = "contract_completion";
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
@ -8,7 +9,7 @@ using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Rewards;
/// <summary> /// <summary>
/// Applies quest <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127). /// Applies quest and contract <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127, NEO-148).
/// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128). /// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128).
/// Reputation grants: <see cref="ReputationOperations"/> (NEO-138). /// Reputation grants: <see cref="ReputationOperations"/> (NEO-138).
/// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130). /// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130).
@ -54,7 +55,105 @@ public static class RewardRouterOperations
return Deny(RewardDeliveryReasonCodes.InvalidQuestId); return Deny(RewardDeliveryReasonCodes.InvalidQuestId);
} }
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var existingEvent)) return TryDeliverBundle(
playerId,
bundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider,
new DeliveryTarget(
normalizedPlayerId,
RewardDeliverySourceKinds.QuestCompletion,
normalizedQuestId,
RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId),
ReputationDeltaSourceKinds.QuestCompletion,
normalizedQuestId,
normalizedQuestId,
string.Empty));
}
/// <summary>
/// Applies <paramref name="bundle"/> item, skill XP, and reputation rows for a contract completion and records
/// <see cref="RewardDeliveryEvent"/> when grants succeed (NEO-148).
/// </summary>
public static RewardDeliveryResult TryDeliverContractCompletion(
string playerId,
string contractInstanceId,
QuestRewardBundleRow? bundle,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId);
if (normalizedPlayerId.Length == 0)
{
return Deny(RewardDeliveryReasonCodes.InvalidPlayerId);
}
var normalizedInstanceId = RewardDeliveryIds.NormalizeContractInstanceId(contractInstanceId);
if (normalizedInstanceId.Length == 0)
{
return Deny(RewardDeliveryReasonCodes.InvalidContractInstanceId);
}
return TryDeliverBundle(
playerId,
bundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider,
new DeliveryTarget(
normalizedPlayerId,
RewardDeliverySourceKinds.ContractCompletion,
normalizedInstanceId,
ContractOutcomeIds.MakeIdempotencyKey(normalizedPlayerId, normalizedInstanceId),
ReputationDeltaSourceKinds.ContractCompletion,
normalizedInstanceId,
string.Empty,
normalizedInstanceId));
}
private static RewardDeliveryResult TryDeliverBundle(
string playerId,
QuestRewardBundleRow? bundle,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider,
DeliveryTarget target)
{
if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var existingEvent))
{ {
return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered); return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
} }
@ -62,7 +161,6 @@ public static class RewardRouterOperations
var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows; var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows;
var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows; var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows;
var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows; var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows;
var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId);
if (itemGrants.Count > 0) if (itemGrants.Count > 0)
{ {
@ -128,8 +226,7 @@ public static class RewardRouterOperations
{ {
CompensatingRevertAll( CompensatingRevertAll(
playerId, playerId,
normalizedQuestId, target,
idempotencyKey,
appliedItems, appliedItems,
appliedSkillXp, appliedSkillXp,
perksUnlockedByThisCall, perksUnlockedByThisCall,
@ -150,8 +247,7 @@ public static class RewardRouterOperations
{ {
CompensatingRevertAll( CompensatingRevertAll(
playerId, playerId,
normalizedQuestId, target,
idempotencyKey,
appliedItems, appliedItems,
appliedSkillXp, appliedSkillXp,
perksUnlockedByThisCall, perksUnlockedByThisCall,
@ -187,14 +283,14 @@ public static class RewardRouterOperations
var appliedReputation = new List<RewardReputationGrantApplied>(reputationGrants.Count); var appliedReputation = new List<RewardReputationGrantApplied>(reputationGrants.Count);
foreach (var grant in reputationGrants) foreach (var grant in reputationGrants)
{ {
var deltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId); var deltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var repOutcome = ReputationOperations.TryApplyDelta( var repOutcome = ReputationOperations.TryApplyDelta(
playerId, playerId,
grant.FactionId, grant.FactionId,
grant.Amount, grant.Amount,
deltaId, deltaId,
ReputationDeltaSourceKinds.QuestCompletion, target.ReputationSourceKind,
normalizedQuestId, target.ReputationSourceId,
standingStore, standingStore,
auditStore, auditStore,
timeProvider); timeProvider);
@ -203,8 +299,7 @@ public static class RewardRouterOperations
{ {
CompensatingRevertAll( CompensatingRevertAll(
playerId, playerId,
normalizedQuestId, target,
idempotencyKey,
appliedItems, appliedItems,
appliedSkillXp, appliedSkillXp,
perksUnlockedByThisCall, perksUnlockedByThisCall,
@ -227,13 +322,15 @@ public static class RewardRouterOperations
var deliveredAt = timeProvider.GetUtcNow(); var deliveredAt = timeProvider.GetUtcNow();
var deliveryEvent = new RewardDeliveryEvent( var deliveryEvent = new RewardDeliveryEvent(
normalizedPlayerId, target.NormalizedPlayerId,
normalizedQuestId, target.QuestId,
deliveredAt, deliveredAt,
appliedItems, appliedItems,
appliedSkillXp, appliedSkillXp,
appliedReputation, appliedReputation,
idempotencyKey); target.IdempotencyKey,
target.SourceKind,
target.ContractInstanceId);
if (deliveryStore.TryRecord(deliveryEvent)) if (deliveryStore.TryRecord(deliveryEvent))
{ {
@ -251,8 +348,7 @@ public static class RewardRouterOperations
// EncounterCompletionOperations when TryMarkCompleted returns false). // EncounterCompletionOperations when TryMarkCompleted returns false).
CompensatingRevertAll( CompensatingRevertAll(
playerId, playerId,
normalizedQuestId, target,
idempotencyKey,
appliedItems, appliedItems,
appliedSkillXp, appliedSkillXp,
perksUnlockedByThisCall, perksUnlockedByThisCall,
@ -267,7 +363,7 @@ public static class RewardRouterOperations
auditStore, auditStore,
timeProvider); timeProvider);
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent)) if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var racedEvent))
{ {
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered); return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
} }
@ -277,8 +373,7 @@ public static class RewardRouterOperations
private static void CompensatingRevertAll( private static void CompensatingRevertAll(
string playerId, string playerId,
string normalizedQuestId, DeliveryTarget target,
string idempotencyKey,
IReadOnlyList<RewardItemGrantApplied> appliedItems, IReadOnlyList<RewardItemGrantApplied> appliedItems,
IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp, IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp,
HashSet<string> perksUnlockedByThisCall, HashSet<string> perksUnlockedByThisCall,
@ -295,8 +390,7 @@ public static class RewardRouterOperations
{ {
CompensatingRevertReputation( CompensatingRevertReputation(
playerId, playerId,
normalizedQuestId, target,
idempotencyKey,
appliedReputation, appliedReputation,
standingStore, standingStore,
auditStore, auditStore,
@ -318,8 +412,7 @@ public static class RewardRouterOperations
private static void CompensatingRevertReputation( private static void CompensatingRevertReputation(
string playerId, string playerId,
string normalizedQuestId, DeliveryTarget target,
string idempotencyKey,
IReadOnlyList<RewardReputationGrantApplied> grants, IReadOnlyList<RewardReputationGrantApplied> grants,
IFactionStandingStore standingStore, IFactionStandingStore standingStore,
IReputationDeltaStore auditStore, IReputationDeltaStore auditStore,
@ -333,15 +426,15 @@ public static class RewardRouterOperations
continue; continue;
} }
var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId); var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var rollbackDeltaId = RewardDeliveryIds.MakeReputationRollbackDeltaId(forwardDeltaId); var rollbackDeltaId = RewardDeliveryIds.MakeReputationRollbackDeltaId(forwardDeltaId);
_ = ReputationOperations.TryApplyDelta( _ = ReputationOperations.TryApplyDelta(
playerId, playerId,
grant.FactionId, grant.FactionId,
-grant.Amount, -grant.Amount,
rollbackDeltaId, rollbackDeltaId,
ReputationDeltaSourceKinds.QuestCompletion, target.ReputationSourceKind,
normalizedQuestId, target.ReputationSourceId,
standingStore, standingStore,
auditStore, auditStore,
timeProvider); timeProvider);
@ -428,4 +521,14 @@ public static class RewardRouterOperations
GrantedReputation: deliveryEvent.GrantedReputation, GrantedReputation: deliveryEvent.GrantedReputation,
DeliveryEvent: deliveryEvent, DeliveryEvent: deliveryEvent,
DeliveredAt: deliveryEvent.DeliveredAt); DeliveredAt: deliveryEvent.DeliveredAt);
private readonly record struct DeliveryTarget(
string NormalizedPlayerId,
string SourceKind,
string SourceId,
string IdempotencyKey,
string ReputationSourceKind,
string ReputationSourceId,
string QuestId,
string ContractInstanceId);
} }

View File

@ -451,39 +451,45 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan:
## Reward delivery store (NEO-126) ## Reward delivery store (NEO-126)
**`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+quest when quest completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**). **`IRewardDeliveryStore`** persists **`RewardDeliveryEvent`** exactly once per player+source when completion bundle grants commit successfully via **`RewardRouterOperations`** (NEO-127, NEO-148). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**).
| Field | Role | | Field | Role |
|-------|------| |-------|------|
| **`PlayerId`**, **`QuestId`** | Normalized keys (trim + lowercase). | | **`PlayerId`** | Normalized key (trim + lowercase). |
| **`SourceKind`** | **`quest_completion`** (NEO-126) or **`contract_completion`** (NEO-148). |
| **`QuestId`** | Set for quest deliveries; empty for contract deliveries. |
| **`ContractInstanceId`** | Set for contract deliveries (NEO-148); empty for quest deliveries. |
| **`DeliveredAt`** | UTC timestamp at delivery record commit. | | **`DeliveredAt`** | UTC timestamp at delivery record commit. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. | | **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) at commit time. |
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) at commit time. | | **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`skillId`, `amount`) at commit time. |
| **`GrantedReputation`** | Snapshot of applied reputation rows (`factionId`, `amount`) at commit time (NEO-138). | | **`GrantedReputation`** | Snapshot of applied reputation rows (`factionId`, `amount`) at commit time (NEO-138). |
| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`** — dedupe input for [E7.M2](../../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md) quest completion delivery. | | **`IdempotencyKey`** | Stable dedupe key — quest: **`{playerId}:quest_complete:{questId}`**; contract: **`{playerId}:contract_complete:{contractInstanceId}`**. |
**Store interface:** **Store interface:**
- **`TryRecord`** — first delivery returns `true`; replays for the same player+quest return `false` without overwriting the snapshot. - **`TryRecord`** — first delivery returns `true`; replays for the same player+source return `false` without overwriting the snapshot.
- **`TryGet`** — read one delivery event for HTTP **`completionRewardSummary`** projection (NEO-129). - **`TryGet(playerId, questId)`** — quest completion lookup for HTTP **`completionRewardSummary`** projection (NEO-129).
- **`TryGet(playerId, sourceKind, sourceId)`** — source-kind-aware lookup (contract completion in NEO-148).
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 loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest and contract completion rewards use this store. Contract outcome audit rows live separately on **`IContractOutcomeStore`** (NEO-146). Postgres persistence deferred. Plans: [NEO-126](../../docs/plans/NEO-126-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md).
## Reward router (NEO-127) ## Reward router (NEO-127)
**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows), **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), and **`ReputationOperations.TryApplyDelta`** with **`ReputationDeltaSourceKinds.QuestCompletion`** (reputation rows — NEO-138), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**. **`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows), **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), and **`ReputationOperations.TryApplyDelta`** with **`ReputationDeltaSourceKinds.QuestCompletion`** (reputation rows — NEO-138), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**.
**`RewardRouterOperations.TryDeliverContractCompletion`** (NEO-148) mirrors the quest path for contract template **`completionRewardBundle`** rows with idempotency key **`{playerId}:contract_complete:{contractInstanceId}`**, **`ReputationDeltaSourceKinds.ContractCompletion`**, and **`SourceKind = contract_completion`** on the delivery event.
| Reason code | When | | Reason code | When |
|-------------|------| |-------------|------|
| **`already_delivered`** | Success replay — store already has a delivery for this player+quest (no re-grant). | | **`already_delivered`** | Success replay — store already has a delivery for this player+source (no re-grant). |
| **`invalid_player_id`**, **`invalid_quest_id`** | Normalized id empty after trim. | | **`invalid_player_id`**, **`invalid_quest_id`**, **`invalid_contract_instance_id`** | Normalized id empty after trim. |
| **`inventory_store_missing`** | Player inventory store could not read/write. | | **`inventory_store_missing`** | Player inventory store could not read/write. |
| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. | | **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. |
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. | | **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`progression_store_missing`** | Skill progression store could not apply XP delta. | | **`progression_store_missing`** | Skill progression store could not apply XP delta. |
| **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). | | **`unknown_faction`**, **`invalid_delta`**, **`audit_append_failed`**, **`invalid_delta_id`**, **`invalid_source`** | Reputation grant denied via **`ReputationOperations`** (NEO-138). |
Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md). Apply order: **items first**, then **skill XP**, then **reputation**. Any deny fails closed without store record (compensating rollback of all prior grant types on partial apply). Reputation **`deltaId`**: **`{idempotencyKey}:rep:{factionId}`**; race/compensating revert uses **`:rollback`** suffix on inverse delta. Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** item grants, reverts skill XP via **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`**, and reverts reputation via inverse **`TryApplyDelta`** — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). **Quest-state wiring (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call **`TryDeliverQuestCompletion`** before **`IPlayerQuestStateStore.TryMarkComplete`**. **Contract wiring (NEO-149):** **`ContractCompletionOperations`** will call **`TryDeliverContractCompletion`**. Bruno quest-flow smoke: `bruno/neon-sprawl-server/quest-progress/Accept grid contract after operator chain.bru` (NEO-138). Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md), [NEO-138](../../docs/plans/NEO-138-implementation-plan.md), [NEO-148](../../docs/plans/NEO-148-implementation-plan.md).
### Reward telemetry hooks (NEO-130) ### Reward telemetry hooks (NEO-130)