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
parent
b20d7d001e
commit
76ce1943c7
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = "");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue