diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md
index bfc7d6d..3bdf964 100644
--- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md
+++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md
@@ -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).
+**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
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.
diff --git a/docs/plans/NEO-148-implementation-plan.md b/docs/plans/NEO-148-implementation-plan.md
index b49704d..0dc442c 100644
--- a/docs/plans/NEO-148-implementation-plan.md
+++ b/docs/plans/NEO-148-implementation-plan.md
@@ -55,8 +55,16 @@ Contract completion payouts reuse E7.M2 delivery idempotency with contract-speci
## Acceptance criteria checklist
-- [ ] First-time contract complete applies bundle exactly once per instance id.
-- [ ] Replay returns **`already_delivered`** without double grant.
+- [x] First-time contract complete applies bundle exactly once per instance id.
+- [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
diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs
index e39699b..8530dbe 100644
--- a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests;
using Xunit;
@@ -124,6 +125,49 @@ public sealed class InMemoryRewardDeliveryStoreTests
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() =>
new(
PlayerId,
diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs
index 673f377..7333d49 100644
--- a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs
+++ b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
@@ -18,6 +19,7 @@ public sealed class RewardRouterOperationsTests
private const string OperatorChainQuestId = "prototype_quest_operator_chain";
private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
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 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 _));
}
+ [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(
RewardRouterTestDependencies deps,
string questId,
@@ -755,7 +932,10 @@ public sealed class RewardRouterOperationsTests
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)
{
@@ -768,6 +948,8 @@ public sealed class RewardRouterOperationsTests
}
public bool TryClear(string playerId, string questId) => true;
+
+ public bool TryClear(string playerId, string sourceKind, string sourceId) => true;
}
/// Second forward rep audit append fails; rollback appends still succeed.
diff --git a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs
index 873a50d..64b080e 100644
--- a/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs
+++ b/server/NeonSprawl.Server/Game/Factions/ReputationDeltaSourceKinds.cs
@@ -4,4 +4,6 @@ namespace NeonSprawl.Server.Game.Factions;
public static class ReputationDeltaSourceKinds
{
public const string QuestCompletion = "quest_completion";
+
+ public const string ContractCompletion = "contract_completion";
}
diff --git a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs
index f7c4860..523faea 100644
--- a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs
@@ -1,16 +1,20 @@
namespace NeonSprawl.Server.Game.Rewards;
-/// Idempotent per-player quest completion reward delivery record (NEO-126).
+/// Idempotent per-player reward delivery records (NEO-126).
public interface IRewardDeliveryStore
{
- /// First record returns true; replays for the same player+quest return false.
+ /// First record returns true; replays for the same player+source return false.
bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
+ bool TryGet(string playerId, string sourceKind, string sourceId, out RewardDeliveryEvent deliveryEvent);
+
///
/// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset.
/// Idempotent when the row is already absent.
///
bool TryClear(string playerId, string questId);
+
+ bool TryClear(string playerId, string sourceKind, string sourceId);
}
diff --git a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs
index 2ecc621..383abf1 100644
--- a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs
@@ -2,7 +2,7 @@ using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Rewards;
-/// Thread-safe in-memory quest completion reward delivery records (NEO-126).
+/// Thread-safe in-memory reward delivery records (NEO-126).
public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
{
private readonly ConcurrentDictionary eventsByKey = new(StringComparer.Ordinal);
@@ -12,7 +12,7 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
///
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
{
- var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent.PlayerId, deliveryEvent.QuestId);
+ var key = RewardDeliveryIds.MakeStoreKey(deliveryEvent);
if (key.Length == 0)
{
return false;
@@ -36,10 +36,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
}
///
- 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)
{
deliveryEvent = default;
- var key = RewardDeliveryIds.MakeStoreKey(playerId, questId);
+ var key = RewardDeliveryIds.MakeStoreKey(playerId, sourceKind, sourceId);
if (key.Length == 0)
{
return false;
@@ -51,10 +55,14 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
}
}
- /// Dev fixture only — removes one player+quest delivery row (Bruno / test reset).
- public bool TryClear(string playerId, string questId)
+ ///
+ public bool TryClear(string playerId, string questId) =>
+ TryClear(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
+
+ /// Dev fixture only — removes one player+source delivery row (Bruno / test reset).
+ 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)
{
return false;
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs
index 15c4eeb..a71dc97 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs
@@ -1,9 +1,9 @@
namespace NeonSprawl.Server.Game.Rewards;
///
-/// Idempotent quest completion reward delivery record (NEO-126).
+/// Idempotent reward delivery record (NEO-126).
/// Persisted via after router apply succeeds (NEO-127).
-/// E9.M1 reward_delivery hook site: TryDeliverQuestCompletion (NEO-130).
+/// E9.M1 reward_delivery hook site: (NEO-130).
///
public readonly record struct RewardDeliveryEvent(
string PlayerId,
@@ -12,4 +12,6 @@ public readonly record struct RewardDeliveryEvent(
IReadOnlyList GrantedItems,
IReadOnlyList GrantedSkillXp,
IReadOnlyList GrantedReputation,
- string IdempotencyKey);
+ string IdempotencyKey,
+ string SourceKind = RewardDeliverySourceKinds.QuestCompletion,
+ string ContractInstanceId = "");
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs
index cc018fe..5d50f0e 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs
@@ -1,3 +1,4 @@
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
namespace NeonSprawl.Server.Game.Rewards;
@@ -20,6 +21,10 @@ public static class RewardDeliveryIds
/// Trim + lowercase; empty when input is null/whitespace.
public static string NormalizeQuestId(string? questId) => NormalizePlayerId(questId);
+ /// Trim + lowercase; empty when input is null/whitespace.
+ public static string NormalizeContractInstanceId(string? contractInstanceId) =>
+ ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
+
/// Stable idempotency key for quest completion delivery.
public static string MakeIdempotencyKey(string? playerId, string? questId)
{
@@ -49,16 +54,47 @@ public static class RewardDeliveryIds
public static string MakeReputationRollbackDeltaId(string reputationDeltaId) =>
reputationDeltaId.Length == 0 ? string.Empty : $"{reputationDeltaId}:rollback";
- /// Composite store key for + .
- public static string MakeStoreKey(string? playerId, string? questId)
+ /// Composite store key for quest completion delivery.
+ public static string MakeStoreKey(string? playerId, string? questId) =>
+ MakeStoreKey(playerId, RewardDeliverySourceKinds.QuestCompletion, questId);
+
+ /// Composite store key for + + source id.
+ public static string MakeStoreKey(string? playerId, string? sourceKind, string? sourceId)
{
var p = NormalizePlayerId(playerId);
- var q = NormalizeQuestId(questId);
- if (p.Length == 0 || q.Length == 0)
+ var kind = NormalizeSourceKind(sourceKind);
+ var id = NormalizeSourceId(sourceKind, sourceId);
+ if (p.Length == 0 || kind.Length == 0 || id.Length == 0)
{
return string.Empty;
}
- return $"{p}\0{q}";
+ return $"{p}\0{kind}\0{id}";
}
+
+ /// Resolves the source id field for a persisted delivery event.
+ public static string GetSourceId(in RewardDeliveryEvent deliveryEvent) =>
+ string.Equals(deliveryEvent.SourceKind, RewardDeliverySourceKinds.ContractCompletion, StringComparison.Ordinal)
+ ? NormalizeContractInstanceId(deliveryEvent.ContractInstanceId)
+ : NormalizeQuestId(deliveryEvent.QuestId);
+
+ /// Composite store key for a persisted delivery event.
+ 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);
}
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs
index 9e40cf2..73603f6 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs
@@ -14,6 +14,8 @@ public static class RewardDeliveryReasonCodes
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 ProgressionStoreMissing = "progression_store_missing";
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs b/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs
new file mode 100644
index 0000000..63b52a9
--- /dev/null
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardDeliverySourceKinds.cs
@@ -0,0 +1,9 @@
+namespace NeonSprawl.Server.Game.Rewards;
+
+/// Stable source-kind values for (NEO-148).
+public static class RewardDeliverySourceKinds
+{
+ public const string QuestCompletion = "quest_completion";
+
+ public const string ContractCompletion = "contract_completion";
+}
diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs
index c2f989b..9e259b3 100644
--- a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs
+++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs
@@ -1,3 +1,4 @@
+using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
@@ -8,7 +9,7 @@ using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Rewards;
///
-/// Applies quest completion grants idempotently (NEO-127).
+/// Applies quest and contract completion grants idempotently (NEO-127, NEO-148).
/// Quest-state wiring: (NEO-128).
/// Reputation grants: (NEO-138).
/// E9.M1 telemetry hook sites: (NEO-130).
@@ -54,7 +55,105 @@ public static class RewardRouterOperations
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));
+ }
+
+ ///
+ /// Applies item, skill XP, and reputation rows for a contract completion and records
+ /// when grants succeed (NEO-148).
+ ///
+ 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);
}
@@ -62,7 +161,6 @@ public static class RewardRouterOperations
var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows;
var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows;
var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows;
- var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId);
if (itemGrants.Count > 0)
{
@@ -128,8 +226,7 @@ public static class RewardRouterOperations
{
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -150,8 +247,7 @@ public static class RewardRouterOperations
{
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -187,14 +283,14 @@ public static class RewardRouterOperations
var appliedReputation = new List(reputationGrants.Count);
foreach (var grant in reputationGrants)
{
- var deltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId);
+ var deltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var repOutcome = ReputationOperations.TryApplyDelta(
playerId,
grant.FactionId,
grant.Amount,
deltaId,
- ReputationDeltaSourceKinds.QuestCompletion,
- normalizedQuestId,
+ target.ReputationSourceKind,
+ target.ReputationSourceId,
standingStore,
auditStore,
timeProvider);
@@ -203,8 +299,7 @@ public static class RewardRouterOperations
{
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -227,13 +322,15 @@ public static class RewardRouterOperations
var deliveredAt = timeProvider.GetUtcNow();
var deliveryEvent = new RewardDeliveryEvent(
- normalizedPlayerId,
- normalizedQuestId,
+ target.NormalizedPlayerId,
+ target.QuestId,
deliveredAt,
appliedItems,
appliedSkillXp,
appliedReputation,
- idempotencyKey);
+ target.IdempotencyKey,
+ target.SourceKind,
+ target.ContractInstanceId);
if (deliveryStore.TryRecord(deliveryEvent))
{
@@ -251,8 +348,7 @@ public static class RewardRouterOperations
// EncounterCompletionOperations when TryMarkCompleted returns false).
CompensatingRevertAll(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
@@ -267,7 +363,7 @@ public static class RewardRouterOperations
auditStore,
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);
}
@@ -277,8 +373,7 @@ public static class RewardRouterOperations
private static void CompensatingRevertAll(
string playerId,
- string normalizedQuestId,
- string idempotencyKey,
+ DeliveryTarget target,
IReadOnlyList appliedItems,
IReadOnlyList appliedSkillXp,
HashSet perksUnlockedByThisCall,
@@ -295,8 +390,7 @@ public static class RewardRouterOperations
{
CompensatingRevertReputation(
playerId,
- normalizedQuestId,
- idempotencyKey,
+ target,
appliedReputation,
standingStore,
auditStore,
@@ -318,8 +412,7 @@ public static class RewardRouterOperations
private static void CompensatingRevertReputation(
string playerId,
- string normalizedQuestId,
- string idempotencyKey,
+ DeliveryTarget target,
IReadOnlyList grants,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
@@ -333,15 +426,15 @@ public static class RewardRouterOperations
continue;
}
- var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, grant.FactionId);
+ var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var rollbackDeltaId = RewardDeliveryIds.MakeReputationRollbackDeltaId(forwardDeltaId);
_ = ReputationOperations.TryApplyDelta(
playerId,
grant.FactionId,
-grant.Amount,
rollbackDeltaId,
- ReputationDeltaSourceKinds.QuestCompletion,
- normalizedQuestId,
+ target.ReputationSourceKind,
+ target.ReputationSourceId,
standingStore,
auditStore,
timeProvider);
@@ -428,4 +521,14 @@ public static class RewardRouterOperations
GrantedReputation: deliveryEvent.GrantedReputation,
DeliveryEvent: deliveryEvent,
DeliveredAt: deliveryEvent.DeliveredAt);
+
+ private readonly record struct DeliveryTarget(
+ string NormalizedPlayerId,
+ string SourceKind,
+ string SourceId,
+ string IdempotencyKey,
+ string ReputationSourceKind,
+ string ReputationSourceId,
+ string QuestId,
+ string ContractInstanceId);
}
diff --git a/server/README.md b/server/README.md
index 5b39d9e..d0e71a4 100644
--- a/server/README.md
+++ b/server/README.md
@@ -451,39 +451,45 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan:
## 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 |
|-------|------|
-| **`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. |
| **`GrantedItems`** | Snapshot of applied item rows (`itemId`, `quantity`) 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). |
-| **`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:**
-- **`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).
+- **`TryRecord`** — first delivery returns `true`; replays for the same player+source return `false` without overwriting the snapshot.
+- **`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)
**`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 |
|-------------|------|
-| **`already_delivered`** | Success replay — store already has a delivery for this player+quest (no re-grant). |
-| **`invalid_player_id`**, **`invalid_quest_id`** | Normalized id empty after trim. |
+| **`already_delivered`** | Success replay — store already has a delivery for this player+source (no re-grant). |
+| **`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_full`**, **`invalid_item`**, **`invalid_quantity`** | Item grant pre-flight or apply failed. |
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`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). |
-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)