Merge pull request #166 from ViPro-Technologies/NEO-127-e7m2-reward-router-operations

NEO-127: E7M2-04 RewardRouterOperations (apply QuestRewardBundle)
pull/167/head
VinPropane 2026-06-07 18:14:54 -04:00 committed by GitHub
commit 4e5d92b6c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1338 additions and 6 deletions

View File

@ -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

View File

@ -160,7 +160,7 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**In scope**
- `RewardRouterOperations.TryDeliverQuestCompletion` in `server/NeonSprawl.Server/Game/Quests/` (or `Game/Rewards/`).
- Item grants via **`PlayerInventoryOperations`**; skill XP via **`MissionRewardSkillXpGrant`**.
- Item grants via **`PlayerInventoryOperations`**; skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (deny envelopes; not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper).
- Deny reason codes (inventory full, unknown item/skill, xp deny).
- Unit + integration tests (AAA): happy path, inventory deny, idempotent replay via store.
@ -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).

View File

@ -0,0 +1,183 @@
# NEO-127 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-127 |
| **Title** | E7M2-04: RewardRouterOperations (apply QuestRewardBundle) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-127/e7m2-04-rewardrouteroperations-apply-questrewardbundle |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-04** |
| **Branch** | `NEO-127-e7m2-reward-router-operations` |
| **Precursor** | [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) **`Done`** — catalog load + bundle validation · [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) **`Done`** — `IRewardDeliveryStore` + `RewardDeliveryEvent` |
| **Pattern** | [NEO-105](NEO-105-implementation-plan.md) — `EncounterCompletionOperations` simulate-all + sequential apply + compensating rollback; [NEO-107](NEO-107-implementation-plan.md) — store `TryRecord` after grants commit |
| **Blocks** | [NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) (`QuestStateOperations.TryMarkComplete` wiring) |
| **Client counterpart** | none — server engine ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle)) |
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M2-04 backlog](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle), [E7M2 kickoff decisions](E7M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-105/NEO-126 code settle:
- **Namespace `Game/Rewards/`** — co-locate router with `IRewardDeliveryStore` (NEO-126); bundle DTOs stay on `QuestDefRow` in `Game/Quests/` (NEO-125).
- **Grant order** — item grants first, skill XP second; compensating **item** rollback on inventory or skill-apply failure (NEO-105 precedent). Prototype bundles have ≤1 skill row; no XP rollback helper exists.
- **Skill XP path** — call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** (not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper) so deny envelopes map to router reason codes.
- **Idempotent replay****`IRewardDeliveryStore.TryGet`** before apply; return success with existing **`RewardDeliveryEvent`** without re-granting (backlog “idempotent replay via store”).
- **Empty bundle** — null or both-empty grant lists → success with empty snapshots and **`TryRecord`** (NEO-126 allows empty arrays; CI guarantees all four prototype quests have bundles).
- **Store race** — if grants succeed but **`TryRecord`** returns `false`, **rollback this call's item + skill XP grants** and return the winner's store event (mirror **`EncounterCompletionOperations`** when **`TryMarkCompleted`** loses); do not retain duplicate grants on the loser.
## Goal, scope, and out-of-scope
**Goal:** Server-internal apply for quest **`completionRewardBundle`** rows (items + skill XP) with structured outcomes and durable idempotency via **`IRewardDeliveryStore`**.
**In scope (from Linear + [E7M2-04](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle)):**
- **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/`.
- Item grants via **`PlayerInventoryOperations`** (simulate-all pre-flight, sequential apply, compensating rollback).
- Skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind.
- **`RewardDeliveryReasonCodes`** + **`RewardDeliveryResult`** (success, deny reason, grant snapshots, **`RewardDeliveryEvent?`**).
- Unit + integration tests (AAA): happy path (skill-only + item+skill bundles), inventory deny, idempotent store replay, apply-phase item rollback, skill deny passthrough.
- `server/README.md` router section.
**Out of scope (from Linear):**
- Quest state transition hooks ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) E7M2-05).
- HTTP DTOs / **`completionRewardSummary`** ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06).
- E9.M1 telemetry hook comments ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) E7M2-07).
- Godot client, Bruno (optional on NEO-128).
## Acceptance criteria checklist
- [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 + per-grant perk tracking + **`TryRevertAppliedSkillGrants`** on **`TryRecord`** race loss, `IRewardDeliveryStore.TryRecord`.
- **Types:** `RewardDeliveryResult`, `RewardDeliveryReasonCodes`.
- **Tests:** 14 AAA tests in `RewardRouterOperationsTests` (…, TryRecord race rollback, concurrent race no double-grant, perk revert on simulated race loss, concurrent level-up winner perk preserved).
- **Docs:** `server/README.md` reward router section; E7.M2 module anchor + alignment register updated.
## Technical approach
### 1. Result + reason types (`Game/Rewards/`)
**`RewardDeliveryReasonCodes`** — stable strings for router denies:
| Code | When |
|------|------|
| **`already_delivered`** | **`IRewardDeliveryStore.TryGet`** hit before apply (idempotent replay — success path, not a deny). |
| **`inventory_store_missing`** | Inventory snapshot unavailable. |
| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Passthrough from **`PlayerInventoryReasonCodes`** via **`PlayerInventoryOperations`**. |
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Passthrough from **`SkillProgressionSnapshotApi`** reason constants via **`TryApplyGrant`**. |
| **`progression_store_missing`** | **`SkillProgressionGrantApplyKind.ProgressionStoreMissing`**. |
**`RewardDeliveryResult`** — readonly record:
| Field | Role |
|-------|------|
| **`Success`** | Grants committed (includes idempotent replay). |
| **`ReasonCode`** | Set when **`Success`** is false. |
| **`GrantedItems`** | Applied item snapshot (`RewardItemGrantApplied`). |
| **`GrantedSkillXp`** | Applied skill XP snapshot (`RewardSkillXpGrantApplied`). |
| **`DeliveryEvent`** | Populated on success (fresh or replay). |
| **`DeliveredAt`** | From event on success. |
### 2. `RewardRouterOperations.TryDeliverQuestCompletion`
Signature (static ops, mirror encounter completion):
```csharp
public static RewardDeliveryResult TryDeliverQuestCompletion(
string playerId,
string questId,
QuestRewardBundleRow? bundle,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
```
**Flow:**
1. **Normalize ids** via **`RewardDeliveryIds`**; fail closed on empty player/quest id (deny with appropriate code or empty reason — match store fail-closed).
2. **Idempotent replay** — if **`deliveryStore.TryGet`**, return **`Success: true`** with existing event and grant snapshots (no inventory/skill mutation).
3. **Resolve grants** — treat null **`bundle`** as empty item + skill lists.
4. **Item pre-flight** — clone inventory snapshot; **`TrySimulateAddStack`** for each **`bundle.ItemGrants`** row in catalog order.
5. **Item apply** — foreach row **`TryAddStack`**; accumulate **`RewardItemGrantApplied`**; on deny/store-missing, return deny **without** skill apply.
6. **Skill XP apply** — foreach **`bundle.SkillXpGrants`** row, **`TryApplyGrant(playerId, skillId, amount, MissionRewardSourceKind, …)`**:
- On **`Denied`**, **`CompensatingRemoveItems`** for applied item rows and return mapped **`ReasonCode`** from **`outcome.Response!.ReasonCode`**.
- On **`ProgressionStoreMissing`**, compensating item rollback + deny.
- On **`Granted`**, append **`RewardSkillXpGrantApplied`**.
7. **Record** — build **`RewardDeliveryEvent`** with **`DeliveredAt = timeProvider.GetUtcNow()`**, **`IdempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(...)`**, grant snapshots.
8. **`deliveryStore.TryRecord`** — if `true`, return fresh success; if `false` (race), **compensating rollback** of this call's item + skill XP grants, then return **`TryGet`** winner event with **`already_delivered`** (or deny if store miss).
**Compensating item rollback** — private helper mirroring **`EncounterCompletionOperations.CompensatingRemoveGrants`**: reverse-order **`TryRemoveStack`** for applied item rows only (no skill XP undo).
**Compensating skill progression rollback** — track perks unlocked per skill grant (`CollectPerksUnlockedSince`); on **`TryRecord`** race loss, **`TryRevertAppliedSkillGrants`** reverses each applied skill XP row via negative delta and removes only perks this call unlocked, then **`ReevaluateAfterLevelUp`** at post-rollback levels re-applies path-auto perks the winner's XP still qualifies for (avoids full snapshot restore clobbering a concurrent winner).
### 3. Skill XP source kind enforcement
Always pass **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** at apply time — content rows do not carry **`sourceKind`** (NEO-124/NEO-43). Startup catalog gates already require skills to allow **`mission_reward`**; router still maps **`source_kind_not_allowed`** if a deny occurs.
### 4. Tests (`RewardRouterOperationsTests`)
Use **`InMemoryWebApplicationFactory`** + resolved DI (same pattern as **`EncounterCompletionOperationsTests`**):
| Test | Coverage |
|------|----------|
| **`TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly`** | **`prototype_quest_gather_intro`**-shaped bundle → salvage +25; store recorded; replay via second call returns same event without double XP. |
| **`TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle`** | **`survey_drone_kit` ×1** + salvage +50; inventory + progression assertions. |
| **`TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord`** | Fill bag; deny **`inventory_full`**; **`TryGet`** false. |
| **`TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied`** | Item grant succeeds then skill deny (inject invalid amount or use test-only bundle row) → item removed; no store record. |
| **`TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered`** | Pre-seed **`IRewardDeliveryStore`**; call router → success, no inventory/XP change. |
| **`TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind`** | Assert grant succeeds for catalog skill; optional negative with skill lacking allowlist (unit-level bundle row). |
Optional host smoke: resolve **`RewardRouterOperations`** is static — assert factory provides all injected stores/registries used in one happy-path call.
### 5. Docs
- **`server/README.md`** — new **Reward router (NEO-127)** section: operation entry point, reason codes, apply order, idempotency + store interaction, pointer to NEO-128 wiring.
- **`documentation_and_implementation_alignment.md`** — E7.M2 row: router apply landed (NEO-127).
- **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor for **`RewardRouterOperations`**.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs` | Stable router deny reason strings. |
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs` | Structured outcome + grant snapshots + optional event. |
| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | `TryDeliverQuestCompletion` apply + rollback + store record. |
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | AAA happy path, deny, rollback, idempotent replay tests. |
| `docs/plans/NEO-127-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/README.md` | Document `RewardRouterOperations`, reason codes, idempotency flow. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — router apply (NEO-127). |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Add NEO-127 implementation anchor after delivery store bullet. |
## Tests
| Target | Coverage |
|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | Skill-only + item+skill happy paths; `inventory_full` fail-closed; item compensating rollback on skill deny; store idempotent replay; `mission_reward` apply path. |
| **Optional** — extend existing host/DI test | Factory resolves all router dependencies (smoke only). |
| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter RewardRouterOperationsTests`; dev server boot unchanged until NEO-128 wires quest complete. |
## Open questions / risks
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| Multi-row skill XP partial apply | Prototype bundles have ≤1 skill row; implement sequential apply with item rollback on any skill deny; defer XP compensating rollback unless a future story requires multi-row bundles. | **adopted** |
| `MissionRewardSkillXpGrant` vs `TryApplyGrant` | Use **`TryApplyGrant`** directly for deny envelopes; keep **`MissionRewardSkillXpGrant`** as HTTP/convenience wrapper only. | **adopted** |
| Null bundle on future quests | Treat as empty grants + record delivery for idempotency; NEO-128 may skip router call when bundle absent — both safe. | **adopted** |
| Quest-state rollback on deny | NEO-128 scope per E7M2 fail-closed decision; NEO-127 returns structured deny only. | **deferred** |
None beyond the above.

View File

@ -0,0 +1,61 @@
# Code review — NEO-127 (E7M2-04)
**Date:** 2026-06-07
**Scope:** Branch `NEO-127-e7m2-reward-router-operations` vs `origin/main` — commits `35b8904``65ca111`
**Base:** `origin/main`
## Verdict
**Approve with nits** — router apply, compensating rollback, idempotent replay, and store record flow mirror the NEO-105 encounter-completion precedent; plan AC and module contracts are satisfied; risk is low (server-internal only until NEO-128 wiring).
## Summary
NEO-127 adds **`RewardRouterOperations.TryDeliverQuestCompletion`** under `Game/Rewards/` to apply quest **`completionRewardBundle`** rows: item grants via **`PlayerInventoryOperations`** (simulate-all pre-flight, sequential apply, compensating rollback) and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind. **`RewardDeliveryResult`** and **`RewardDeliveryReasonCodes`** provide structured outcomes; **`IRewardDeliveryStore`** records **`RewardDeliveryEvent`** on success with idempotent replay via **`TryGet`** before apply and race handling when **`TryRecord`** returns `false`. Ten AAA integration tests cover happy paths, inventory deny, skill deny rollback, store replay, unknown-skill deny, null-bundle empty record, and invalid-id deny. Docs (`NEO-127` plan reconciliation, E7.M2 module anchor, alignment register, `server/README.md`) are updated. No player-visible behavior until NEO-128 wires quest completion.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-127-implementation-plan.md` | **Matches** — kickoff decisions adopted; AC checked; reconciliation accurate. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-04) | **Matches** — AC checkboxes complete; in-scope skill XP path aligned with **`TryApplyGrant`**. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-127 implementation anchor documents router apply path. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-04 router apply; row correctly stays **Planned** until NEO-132 capstone. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required for this slice. |
| `server/README.md` | **Matches** — reward router section documents entry point, reason codes, apply order, idempotency + store race behavior. |
| Full-stack epic decomposition | **N/A** — E7M2-04 is server engine only; no client counterpart required. |
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate.
## Blocking issues
None.
## Suggestions
1. ~~**Empty / null bundle test** — Kickoff decision and plan both require success with empty grant snapshots and **`TryRecord`** for null or both-empty bundles. Code handles this (skips inventory pre-flight, records empty arrays), but no test locks the behavior. A single AAA test with `bundle: null` or `new QuestRewardBundleRow([], [])` would close the gap.~~ **Done.** `TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull`.
2. ~~**Invalid id deny tests** — `invalid_player_id` / `invalid_quest_id` are implemented and documented in `server/README.md`, but untested. Two small deny-path tests (empty/whitespace ids) would match the fail-closed store precedent from NEO-126.~~ **Done.** `TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty` and `…InvalidQuestId…`.
3. ~~**Replay XP assertion** — In `TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly`, the second-call assertion reuses `salvageXp` captured before the replay `Deliver` call. Re-fetch **`GetXpTotals`** after the second call to prove no double XP grant on idempotent replay.~~ **Done.** Gather intro test re-fetches XP after replay.
## Nits
- Nit: Pre-flight **`TrySimulateAddStack`** failure always denies with **`inventory_full`**, even when the underlying cause would be **`invalid_item`** or **`invalid_quantity`** at apply time. Same as **`EncounterCompletionOperations`** (NEO-105); acceptable for prototype catalog-gated content; apply-phase denies still return correct passthrough codes.
- Nit: When **`TryRecord`** returns `false` and a subsequent **`TryGet`** also misses, the router still returns success with the locally built **`deliveryEvent`** (line 149 fallback). Extremely unlikely with the in-memory store; mirrors “retain grants, treat as idempotent success” policy from the plan. Consider a debug log if this path ever surfaces in production persistence.
- Nit: **`CompensatingRemoveItems`** ignores **`TryRemoveStack`** outcomes (same as encounter compensating helper). Acceptable for prototype; multi-item bundles with partial rollback failure would be a future hardening item.
- Nit: Optional plan test for **`source_kind_not_allowed`** negative path not implemented; catalog startup gates make this low priority.
## Verification
```bash
dotnet test server/NeonSprawl.Server.Tests --filter RewardRouterOperationsTests
```
All 14 tests pass (verified 2026-06-07; includes concurrent level-up winner perk preservation on **`TryRecord`** race loss).
Optional before merge:
- Boot dev server; confirm `/health` 200 — no new DI registration; behavior unchanged until NEO-128.

View File

@ -0,0 +1,565 @@
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 const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1";
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);
var xpAfterReplay = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfterReplay.TryGetValue("salvage", out var salvageAfterReplay));
Assert.Equal(salvageBefore + 25, salvageAfterReplay);
}
[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 _));
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
// Act
var result = Deliver(deps, GatherQuestId, bundle: null, TimeProvider.System);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Empty(result.GrantedItems);
Assert.Empty(result.GrantedSkillXp);
Assert.NotNull(result.DeliveryEvent);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
Assert.Empty(stored.GrantedItems);
Assert.Empty(stored.GrantedSkillXp);
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!));
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
" ",
GatherQuestId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InvalidPlayerId, result.ReasonCode);
Assert.False(deps.DeliveryStore.TryGet(" ", GatherQuestId, out _));
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldDenyInvalidQuestId_WhenQuestIdIsEmpty()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
PlayerId,
" ",
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InvalidQuestId, result.ReasonCode);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _));
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var winnerEvent = new RewardDeliveryEvent(
PlayerId,
GatherQuestId,
DeliveredAt,
[],
[new RewardSkillXpGrantApplied("salvage", 25)],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
PlayerId,
GatherQuestId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
store,
TimeProvider.System);
// Assert
Assert.True(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
Assert.Equal(winnerEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey);
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
Assert.Equal(salvageBefore, salvageAfter);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425));
var pick = deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency");
Assert.True(pick.Success);
var perkBefore = deps.PerkStore.GetSnapshot(PlayerId);
Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkBefore.UnlockedPerkIds);
var winnerEvent = new RewardDeliveryEvent(
PlayerId,
GatherQuestId,
DeliveredAt,
[],
[new RewardSkillXpGrantApplied("salvage", 25)],
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
// Act
var result = RewardRouterOperations.TryDeliverQuestCompletion(
PlayerId,
GatherQuestId,
bundle,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
store,
TimeProvider.System);
// Assert
Assert.True(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
Assert.Equal(425, salvageAfter);
var perkAfter = deps.PerkStore.GetSnapshot(PlayerId);
Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds);
Assert.Equal(perkBefore.UnlockedPerkIds, perkAfter.UnlockedPerkIds);
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldNotDoubleGrant_WhenConcurrentRecordRaces()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var bundle = new QuestRewardBundleRow(
[new RewardGrantRow("survey_drone_kit", 1)],
[new QuestSkillXpGrantRow("salvage", 25)]);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
var kitBefore = CountBagItem(inventoryBefore!, "survey_drone_kit");
var barrier = new Barrier(2);
RewardDeliveryResult result1 = default;
RewardDeliveryResult result2 = default;
// Act
await Task.WhenAll(
Task.Run(() =>
{
barrier.SignalAndWait();
result1 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
}),
Task.Run(() =>
{
barrier.SignalAndWait();
result2 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
}));
// Assert
Assert.True(result1.Success);
Assert.True(result2.Success);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
Assert.Equal(salvageBefore + 25, salvageAfter);
Assert.Equal(kitBefore + 1, CountBagItem(inventoryAfter!, "survey_drone_kit"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
}
[Fact]
public async Task TryDeliverQuestCompletion_ShouldPreserveWinnerPerks_WhenConcurrentRecordRacesAfterLevelUp()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425));
Assert.True(deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency").Success);
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
var barrier = new Barrier(2);
RewardDeliveryResult result1 = default;
RewardDeliveryResult result2 = default;
// Act
await Task.WhenAll(
Task.Run(() =>
{
barrier.SignalAndWait();
result1 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
}),
Task.Run(() =>
{
barrier.SignalAndWait();
result2 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
}));
// Assert
Assert.True(result1.Success);
Assert.True(result2.Success);
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
Assert.Equal(450, salvageAfter);
var perkAfter = deps.PerkStore.GetSnapshot(PlayerId);
Assert.Contains(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds);
Assert.True(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.PerkStore,
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<IPlayerPerkStateStore>(),
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 static int TotalBagQuantity(PlayerInventorySnapshot snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
total += slot.Quantity;
}
}
return total;
}
private sealed record RewardRouterTestDependencies(
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
/// <summary>TryGet misses until after a failed <see cref="IRewardDeliveryStore.TryRecord"/> — simulates concurrent race loser.</summary>
private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore
{
private bool recordAttempted;
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
{
recordAttempted = true;
return false;
}
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
{
if (!recordAttempted)
{
deliveryEvent = default;
return false;
}
deliveryEvent = winnerEvent;
return true;
}
}
}

View File

@ -14,9 +14,15 @@ public interface IPlayerPerkStateStore
/// <summary>Adds perks not already unlocked; fails when player cannot be written.</summary>
bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds);
/// <summary>Removes unlocked perks present in the store; fails when player cannot be written.</summary>
bool TryRemoveUnlockedPerks(string playerId, IEnumerable<string> perkIds);
/// <summary>Removes a branch pick (engine rollback when perk unlock fails after pick).</summary>
bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex);
/// <summary>Clears all branch picks and unlocked perks for the player (NEO-48 dev fixture API).</summary>
bool TryResetPerkState(string playerId);
/// <summary>Replaces branch picks and unlocked perks with <paramref name="snapshot"/> (grant rollback).</summary>
bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot);
}

View File

@ -91,6 +91,32 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
}
}
/// <inheritdoc />
public bool TryRemoveUnlockedPerks(string playerId, IEnumerable<string> perkIds)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
foreach (var raw in perkIds)
{
var pid = raw.Trim();
if (pid.Length == 0)
{
continue;
}
inner.RemoveUnlockedPerk(pid);
}
return true;
}
}
/// <inheritdoc />
public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex)
{
@ -123,6 +149,35 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
}
}
/// <inheritdoc />
public bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
inner.Reset();
foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId)
{
foreach (var (tierIndex, branchId) in tiers)
{
inner.SetBranchPick(skillId, tierIndex, branchId);
}
}
foreach (var perkId in snapshot.UnlockedPerkIds)
{
inner.AddUnlockedPerk(perkId);
}
return true;
}
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
@ -156,6 +211,8 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId);
public void RemoveUnlockedPerk(string perkId) => unlockedPerks.Remove(perkId);
public bool RemoveBranchPick(string skillId, int tierIndex) =>
branchPicks.TryGetValue(skillId, out var tiers) && tiers.Remove(tierIndex);

View File

@ -193,6 +193,45 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou
return true;
}
/// <inheritdoc />
public bool TryRemoveUnlockedPerks(string playerId, IEnumerable<string> perkIds)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return false;
}
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
return false;
}
foreach (var raw in perkIds)
{
var perkId = raw.Trim();
if (perkId.Length == 0)
{
continue;
}
using var delete = new Npgsql.NpgsqlCommand(
"DELETE FROM player_unlocked_perk WHERE player_id = @pid AND perk_id = @perk;",
conn,
tx);
delete.Parameters.AddWithValue("pid", norm);
delete.Parameters.AddWithValue("perk", perkId);
delete.ExecuteNonQuery();
}
tx.Commit();
return true;
}
/// <inheritdoc />
public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex)
{
@ -257,6 +296,79 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou
return true;
}
/// <inheritdoc />
public bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return false;
}
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
return false;
}
using (var picks = new Npgsql.NpgsqlCommand(
"DELETE FROM player_mastery_branch_pick WHERE player_id = @pid;",
conn,
tx))
{
picks.Parameters.AddWithValue("pid", norm);
picks.ExecuteNonQuery();
}
using (var perks = new Npgsql.NpgsqlCommand(
"DELETE FROM player_unlocked_perk WHERE player_id = @pid;",
conn,
tx))
{
perks.Parameters.AddWithValue("pid", norm);
perks.ExecuteNonQuery();
}
foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId)
{
foreach (var (tierIndex, branchId) in tiers)
{
using var insert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id, updated_at)
VALUES (@pid, @sid, @tier, @bid, now());
""",
conn,
tx);
insert.Parameters.AddWithValue("pid", norm);
insert.Parameters.AddWithValue("sid", skillId);
insert.Parameters.AddWithValue("tier", tierIndex);
insert.Parameters.AddWithValue("bid", branchId);
insert.ExecuteNonQuery();
}
}
foreach (var perkId in snapshot.UnlockedPerkIds)
{
using var upsert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_unlocked_perk (player_id, perk_id, unlocked_at)
VALUES (@pid, @perk, now());
""",
conn,
tx);
upsert.Parameters.AddWithValue("pid", norm);
upsert.Parameters.AddWithValue("perk", perkId);
upsert.ExecuteNonQuery();
}
tx.Commit();
return true;
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
{
using var cmd = new Npgsql.NpgsqlCommand(

View File

@ -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;
}

View File

@ -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);

View File

@ -0,0 +1,241 @@
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,
IPlayerPerkStateStore perkStore,
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 perksUnlockedByThisCall = new HashSet<string>(StringComparer.Ordinal);
var appliedSkillXp = new List<RewardSkillXpGrantApplied>(skillGrants.Count);
foreach (var grant in skillGrants)
{
var perkBeforeGrant = perkStore.GetSnapshot(playerId);
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));
SkillProgressionGrantOperations.CollectPerksUnlockedSince(
perkBeforeGrant,
perkStore.GetSnapshot(playerId),
perksUnlockedByThisCall);
}
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);
}
// Lost TryRecord race after apply — rollback this call's grants (mirror
// EncounterCompletionOperations when TryMarkCompleted returns false).
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
_ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants(
playerId,
appliedSkillXp,
perksUnlockedByThisCall,
skillProgressionStore,
perkStore);
ResyncPathAutoPerksAfterRaceRollback(
playerId,
appliedSkillXp,
skillProgressionStore,
levelCurve,
perkUnlockEngine);
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
{
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
return Deny(RewardDeliveryReasonCodes.AlreadyDelivered);
}
private static void ResyncPathAutoPerksAfterRaceRollback(
string playerId,
IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine)
{
if (appliedSkillXp.Count == 0)
{
return;
}
var xpBySkill = skillProgressionStore.GetXpTotals(playerId);
foreach (var grant in appliedSkillXp)
{
xpBySkill.TryGetValue(grant.SkillId, out var totalXp);
var level = levelCurve.LevelFromTotalXp(totalXp);
_ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, grant.SkillId, level);
}
}
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);
}

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Skills;
@ -101,4 +102,40 @@ public static class SkillProgressionGrantOperations
LevelUps = levelUps,
});
}
/// <summary>Collects perk ids unlocked between two snapshots (this batch only).</summary>
public static void CollectPerksUnlockedSince(
PerkStateSnapshot before,
PerkStateSnapshot after,
ISet<string> sink)
{
foreach (var perkId in after.UnlockedPerkIds)
{
if (!before.UnlockedPerkIds.Contains(perkId))
{
sink.Add(perkId);
}
}
}
/// <summary>Reverts skill XP grants and perks unlocked during this failed batch commit.</summary>
public static bool TryRevertAppliedSkillGrants(
string playerId,
IReadOnlyList<RewardSkillXpGrantApplied> appliedGrants,
IReadOnlyCollection<string> perksUnlockedByBatch,
IPlayerSkillProgressionStore xpStore,
IPlayerPerkStateStore perkStore)
{
for (var i = appliedGrants.Count - 1; i >= 0; i--)
{
var grant = appliedGrants[i];
if (!xpStore.TryApplyXpDelta(playerId, grant.SkillId, -grant.Amount, out _, out _))
{
return false;
}
}
return perksUnlockedByBatch.Count == 0
|| perkStore.TryRemoveUnlockedPerks(playerId, perksUnlockedByBatch);
}
}

View File

@ -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` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). 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/`.