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)