NEO-127: add RewardRouterOperations for quest completion bundles
Apply item grants and mission_reward skill XP with compensating rollback, idempotent IRewardDeliveryStore recording, and AAA integration tests.pull/166/head
parent
35b8904f15
commit
5b84ffd75a
|
|
@ -68,4 +68,6 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
|
|||
|
||||
**NEO-107 (E5.M3 producer hook):** On encounter completion grant commit, **`EncounterCompletionOperations.TryCompleteAndGrant`** records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** (idempotency key **`{playerId}:{encounterId}`**). Future E7.M2 **`QuestRewardBundle`** router consumes this record for **`reward_delivery`** quest credit beyond prototype **`contract_handoff_token`** item loot — comment-only hook stub; no runtime router yet. See [NEO-107 implementation plan](../../plans/NEO-107-implementation-plan.md); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107).
|
||||
|
||||
**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Router apply and quest-state wiring land in NEO-127/NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126).
|
||||
**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Quest-state wiring lands in NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126).
|
||||
|
||||
**NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -170,8 +170,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record.
|
||||
- [ ] Skill XP uses **`mission_reward`** source kind only.
|
||||
- [x] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record.
|
||||
- [x] Skill XP uses **`mission_reward`** source kind only.
|
||||
|
||||
**Client counterpart:** none (server engine).
|
||||
|
||||
|
|
|
|||
|
|
@ -47,8 +47,15 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record.
|
||||
- [ ] Skill XP uses **`mission_reward`** source kind only.
|
||||
- [x] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record.
|
||||
- [x] Skill XP uses **`mission_reward`** source kind only.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Operations:** `RewardRouterOperations.TryDeliverQuestCompletion` — item pre-flight + apply, skill XP via `TryApplyGrant` (`mission_reward`), compensating item rollback, `IRewardDeliveryStore.TryRecord`.
|
||||
- **Types:** `RewardDeliveryResult`, `RewardDeliveryReasonCodes`.
|
||||
- **Tests:** 7 AAA tests in `RewardRouterOperationsTests` (skill-only + item+skill happy paths, inventory deny, skill deny rollback, store replay, mission_reward apply, unknown skill deny).
|
||||
- **Docs:** `server/README.md` reward router section; E7.M2 module anchor + alignment register updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Rewards;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Rewards;
|
||||
|
||||
public sealed class RewardRouterOperationsTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GatherQuestId = "prototype_quest_gather_intro";
|
||||
private const string OperatorChainQuestId = "prototype_quest_operator_chain";
|
||||
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
var timeProvider = TimeProvider.System;
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
|
||||
// Act
|
||||
var first = Deliver(deps, GatherQuestId, bundle, timeProvider);
|
||||
var second = Deliver(deps, GatherQuestId, bundle, timeProvider);
|
||||
|
||||
// Assert
|
||||
Assert.True(first.Success);
|
||||
Assert.Null(first.ReasonCode);
|
||||
Assert.Empty(first.GrantedItems);
|
||||
Assert.Single(first.GrantedSkillXp);
|
||||
Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId);
|
||||
Assert.Equal(25, first.GrantedSkillXp[0].Amount);
|
||||
Assert.NotNull(first.DeliveryEvent);
|
||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
|
||||
Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey);
|
||||
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 + 25, salvageXp);
|
||||
Assert.True(second.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
|
||||
Assert.Equal(salvageBefore + 25, salvageXp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]);
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Single(result.GrantedItems);
|
||||
Assert.Equal("survey_drone_kit", result.GrantedItems[0].ItemId);
|
||||
Assert.Equal(1, result.GrantedItems[0].Quantity);
|
||||
Assert.Single(result.GrantedSkillXp);
|
||||
Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId);
|
||||
Assert.Equal(50, result.GrantedSkillXp[0].Amount);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit"));
|
||||
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 + 50, salvageXp);
|
||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
FillBag(deps);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
|
||||
Assert.Empty(result.GrantedItems);
|
||||
Assert.Empty(result.GrantedSkillXp);
|
||||
Assert.Null(result.DeliveryEvent);
|
||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 0)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.InvalidAmount, result.ReasonCode);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
Assert.Equal(
|
||||
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
||||
CountBagItem(afterInventory!, "survey_drone_kit"));
|
||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var existingEvent = new RewardDeliveryEvent(
|
||||
PlayerId,
|
||||
OperatorChainQuestId,
|
||||
DeliveredAt,
|
||||
[new RewardItemGrantApplied("survey_drone_kit", 1)],
|
||||
[new RewardSkillXpGrantApplied("salvage", 50)],
|
||||
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
|
||||
Assert.True(deps.DeliveryStore.TryRecord(existingEvent));
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
||||
Assert.Equal(existingEvent.DeliveredAt, result.DeliveredAt);
|
||||
Assert.Equal(existingEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
Assert.Equal(
|
||||
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
||||
CountBagItem(afterInventory!, "survey_drone_kit"));
|
||||
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind_WhenCatalogAllowsIt()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("refine", 25)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, "prototype_quest_refine_intro", bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
var totals = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
Assert.True(totals.TryGetValue("refine", out var refineXp));
|
||||
Assert.Equal(25, refineXp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldDenyUnknownSkill_WhenSkillIdIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("unknown_skill_id", 25)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.UnknownSkill, result.ReasonCode);
|
||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
|
||||
}
|
||||
|
||||
private static RewardDeliveryResult Deliver(
|
||||
RewardRouterTestDependencies deps,
|
||||
string questId,
|
||||
QuestRewardBundleRow bundle,
|
||||
TimeProvider timeProvider) =>
|
||||
RewardRouterOperations.TryDeliverQuestCompletion(
|
||||
PlayerId,
|
||||
questId,
|
||||
bundle,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.DeliveryStore,
|
||||
timeProvider);
|
||||
|
||||
private static void FillBag(RewardRouterTestDependencies deps)
|
||||
{
|
||||
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
|
||||
{
|
||||
var add = PlayerInventoryOperations.TryAddStack(
|
||||
PlayerId,
|
||||
"survey_drone_kit",
|
||||
quantity: 1,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore);
|
||||
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
private static RewardRouterTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||
{
|
||||
var services = factory.Services;
|
||||
return new RewardRouterTestDependencies(
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var slot in snapshot.BagSlots)
|
||||
{
|
||||
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
||||
{
|
||||
total += slot.Quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private sealed record RewardRouterTestDependencies(
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IRewardDeliveryStore DeliveryStore);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Rewards;
|
||||
|
||||
/// <summary>Stable deny reason codes for quest completion reward delivery (NEO-127).</summary>
|
||||
public static class RewardDeliveryReasonCodes
|
||||
{
|
||||
/// <summary>Idempotent replay — success path when <see cref="IRewardDeliveryStore.TryGet"/> hits before apply.</summary>
|
||||
public const string AlreadyDelivered = "already_delivered";
|
||||
|
||||
public const string InvalidPlayerId = "invalid_player_id";
|
||||
|
||||
public const string InvalidQuestId = "invalid_quest_id";
|
||||
|
||||
public const string InventoryStoreMissing = "inventory_store_missing";
|
||||
|
||||
public const string ProgressionStoreMissing = "progression_store_missing";
|
||||
|
||||
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InventoryFull"/>.</summary>
|
||||
public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull;
|
||||
|
||||
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidItem"/>.</summary>
|
||||
public const string InvalidItem = PlayerInventoryReasonCodes.InvalidItem;
|
||||
|
||||
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidQuantity"/>.</summary>
|
||||
public const string InvalidQuantity = PlayerInventoryReasonCodes.InvalidQuantity;
|
||||
|
||||
/// <summary>Passthrough from <see cref="SkillProgressionSnapshotApi.ReasonUnknownSkill"/>.</summary>
|
||||
public const string UnknownSkill = SkillProgressionSnapshotApi.ReasonUnknownSkill;
|
||||
|
||||
/// <summary>Passthrough from <see cref="SkillProgressionSnapshotApi.ReasonInvalidAmount"/>.</summary>
|
||||
public const string InvalidAmount = SkillProgressionSnapshotApi.ReasonInvalidAmount;
|
||||
|
||||
/// <summary>Passthrough from <see cref="SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed"/>.</summary>
|
||||
public const string SourceKindNotAllowed = SkillProgressionSnapshotApi.ReasonSourceKindNotAllowed;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
namespace NeonSprawl.Server.Game.Rewards;
|
||||
|
||||
/// <summary>
|
||||
/// Server-internal quest completion reward delivery envelope (NEO-127).
|
||||
/// Quest-state wiring promotes via NEO-128; HTTP DTOs via NEO-129.
|
||||
/// </summary>
|
||||
/// <param name="GrantedItems">Item grants on success; empty when denied.</param>
|
||||
/// <param name="GrantedSkillXp">Skill XP grants on success; empty when denied.</param>
|
||||
/// <param name="DeliveryEvent">Delivery payload on success; null when denied.</param>
|
||||
public readonly record struct RewardDeliveryResult(
|
||||
bool Success,
|
||||
string? ReasonCode,
|
||||
IReadOnlyList<RewardItemGrantApplied> GrantedItems,
|
||||
IReadOnlyList<RewardSkillXpGrantApplied> GrantedSkillXp,
|
||||
RewardDeliveryEvent? DeliveryEvent,
|
||||
DateTimeOffset? DeliveredAt);
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Rewards;
|
||||
|
||||
/// <summary>
|
||||
/// Applies quest <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127).
|
||||
/// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128).
|
||||
/// </summary>
|
||||
public static class RewardRouterOperations
|
||||
{
|
||||
private static readonly RewardItemGrantApplied[] EmptyItemGrants = [];
|
||||
private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = [];
|
||||
|
||||
/// <summary>
|
||||
/// Applies <paramref name="bundle"/> item + skill XP rows for a quest completion and records
|
||||
/// <see cref="RewardDeliveryEvent"/> when grants succeed.
|
||||
/// </summary>
|
||||
public static RewardDeliveryResult TryDeliverQuestCompletion(
|
||||
string playerId,
|
||||
string questId,
|
||||
QuestRewardBundleRow? bundle,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore,
|
||||
ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore skillProgressionStore,
|
||||
ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine,
|
||||
IRewardDeliveryStore deliveryStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId);
|
||||
if (normalizedPlayerId.Length == 0)
|
||||
{
|
||||
return Deny(RewardDeliveryReasonCodes.InvalidPlayerId);
|
||||
}
|
||||
|
||||
var normalizedQuestId = RewardDeliveryIds.NormalizeQuestId(questId);
|
||||
if (normalizedQuestId.Length == 0)
|
||||
{
|
||||
return Deny(RewardDeliveryReasonCodes.InvalidQuestId);
|
||||
}
|
||||
|
||||
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var existingEvent))
|
||||
{
|
||||
return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
|
||||
}
|
||||
|
||||
var itemGrants = bundle?.ItemGrants ?? Array.Empty<RewardGrantRow>();
|
||||
var skillGrants = bundle?.SkillXpGrants ?? Array.Empty<QuestSkillXpGrantRow>();
|
||||
|
||||
if (itemGrants.Count > 0)
|
||||
{
|
||||
if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot))
|
||||
{
|
||||
return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing);
|
||||
}
|
||||
|
||||
var simulated = snapshot.Clone();
|
||||
foreach (var grant in itemGrants)
|
||||
{
|
||||
if (!PlayerInventoryOperations.TrySimulateAddStack(
|
||||
ref simulated,
|
||||
grant.ItemId,
|
||||
grant.Quantity,
|
||||
itemRegistry))
|
||||
{
|
||||
return Deny(RewardDeliveryReasonCodes.InventoryFull);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var appliedItems = new List<RewardItemGrantApplied>(itemGrants.Count);
|
||||
foreach (var grant in itemGrants)
|
||||
{
|
||||
var addOutcome = PlayerInventoryOperations.TryAddStack(
|
||||
playerId,
|
||||
grant.ItemId,
|
||||
grant.Quantity,
|
||||
itemRegistry,
|
||||
inventoryStore);
|
||||
|
||||
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
|
||||
{
|
||||
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
|
||||
return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing);
|
||||
}
|
||||
|
||||
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
|
||||
{
|
||||
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
|
||||
return Deny(MapInventoryReason(addOutcome.ReasonCode));
|
||||
}
|
||||
|
||||
appliedItems.Add(new RewardItemGrantApplied(grant.ItemId, grant.Quantity));
|
||||
}
|
||||
|
||||
var appliedSkillXp = new List<RewardSkillXpGrantApplied>(skillGrants.Count);
|
||||
foreach (var grant in skillGrants)
|
||||
{
|
||||
var skillOutcome = SkillProgressionGrantOperations.TryApplyGrant(
|
||||
playerId,
|
||||
grant.SkillId,
|
||||
grant.Amount,
|
||||
MissionRewardSkillXpConstants.MissionRewardSourceKind,
|
||||
skillRegistry,
|
||||
skillProgressionStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine);
|
||||
|
||||
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing)
|
||||
{
|
||||
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
|
||||
return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing);
|
||||
}
|
||||
|
||||
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied)
|
||||
{
|
||||
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
|
||||
var reasonCode = skillOutcome.Response?.ReasonCode ?? RewardDeliveryReasonCodes.UnknownSkill;
|
||||
return Deny(reasonCode);
|
||||
}
|
||||
|
||||
appliedSkillXp.Add(new RewardSkillXpGrantApplied(grant.SkillId, grant.Amount));
|
||||
}
|
||||
|
||||
var deliveredAt = timeProvider.GetUtcNow();
|
||||
var deliveryEvent = new RewardDeliveryEvent(
|
||||
normalizedPlayerId,
|
||||
normalizedQuestId,
|
||||
deliveredAt,
|
||||
appliedItems,
|
||||
appliedSkillXp,
|
||||
RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId));
|
||||
|
||||
if (deliveryStore.TryRecord(deliveryEvent))
|
||||
{
|
||||
return SuccessFromEvent(deliveryEvent, reasonCode: null);
|
||||
}
|
||||
|
||||
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
|
||||
{
|
||||
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
|
||||
}
|
||||
|
||||
return SuccessFromEvent(deliveryEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
|
||||
}
|
||||
|
||||
private static string MapInventoryReason(string? reasonCode) =>
|
||||
reasonCode switch
|
||||
{
|
||||
PlayerInventoryReasonCodes.InventoryFull => RewardDeliveryReasonCodes.InventoryFull,
|
||||
PlayerInventoryReasonCodes.InvalidItem => RewardDeliveryReasonCodes.InvalidItem,
|
||||
PlayerInventoryReasonCodes.InvalidQuantity => RewardDeliveryReasonCodes.InvalidQuantity,
|
||||
null => RewardDeliveryReasonCodes.InventoryFull,
|
||||
_ => reasonCode,
|
||||
};
|
||||
|
||||
private static void CompensatingRemoveItems(
|
||||
string playerId,
|
||||
IReadOnlyList<RewardItemGrantApplied> grants,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore)
|
||||
{
|
||||
for (var i = grants.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var grant = grants[i];
|
||||
_ = PlayerInventoryOperations.TryRemoveStack(
|
||||
playerId,
|
||||
grant.ItemId,
|
||||
grant.Quantity,
|
||||
itemRegistry,
|
||||
inventoryStore);
|
||||
}
|
||||
}
|
||||
|
||||
private static RewardDeliveryResult Deny(string reasonCode) =>
|
||||
new(
|
||||
Success: false,
|
||||
ReasonCode: reasonCode,
|
||||
GrantedItems: EmptyItemGrants,
|
||||
GrantedSkillXp: EmptySkillXpGrants,
|
||||
DeliveryEvent: null,
|
||||
DeliveredAt: null);
|
||||
|
||||
private static RewardDeliveryResult SuccessFromEvent(RewardDeliveryEvent deliveryEvent, string? reasonCode) =>
|
||||
new(
|
||||
Success: true,
|
||||
ReasonCode: reasonCode,
|
||||
GrantedItems: deliveryEvent.GrantedItems,
|
||||
GrantedSkillXp: deliveryEvent.GrantedSkillXp,
|
||||
DeliveryEvent: deliveryEvent,
|
||||
DeliveredAt: deliveryEvent.DeliveredAt);
|
||||
}
|
||||
|
|
@ -267,7 +267,7 @@ 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 (router apply lands in NEO-127). Prototype uses **`InMemoryRewardDeliveryStore`** (registered via **`AddRewardDeliveryStore`** in **`Program.cs`**).
|
||||
**`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`**).
|
||||
|
||||
| Field | Role |
|
||||
|-------|------|
|
||||
|
|
@ -284,6 +284,21 @@ Manual QA: [`docs/manual-qa/NEO-121.md`](../../docs/manual-qa/NEO-121.md); plan:
|
|||
|
||||
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).
|
||||
|
||||
## Reward router (NEO-127)
|
||||
|
||||
**`RewardRouterOperations.TryDeliverQuestCompletion`** applies a quest **`completionRewardBundle`** via **`PlayerInventoryOperations`** (item rows) and **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (skill XP rows), then records **`RewardDeliveryEvent`** in **`IRewardDeliveryStore`**.
|
||||
|
||||
| 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. |
|
||||
| **`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. |
|
||||
|
||||
Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (race), grants are retained and the existing store event is returned. Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md).
|
||||
|
||||
## Encounter definitions (NEO-103)
|
||||
|
||||
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue