NEO-128: wire quest complete to reward router deliver-then-mark.

TryMarkComplete delivers completionRewardBundle before marking complete;
objective wiring and upstream ops thread reward dependencies. Tests cover
bundle delivery, idempotent replay, and inventory-full deny.
pull/167/head
VinPropane 2026-06-07 18:24:01 -04:00
parent 7abe7dcafd
commit da99769ddf
25 changed files with 764 additions and 117 deletions

View File

@ -0,0 +1,28 @@
meta {
name: GET health (quest complete reward wiring NEO-128)
type: http
seq: 3
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-128 wires RewardRouterOperations into QuestStateOperations.TryMarkComplete (deliver-then-mark).
No new HTTP routes — quest-progress GET/accept POST signatures gained reward-router DI only.
Use this request to confirm the host started after wiring; verify bundle delivery via dotnet test
(QuestStateOperationsTests, QuestObjectiveWiringTests).
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -71,3 +71,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
**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-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). **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).
**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. HTTP **`completionRewardSummary`** in NEO-129. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).

File diff suppressed because one or more lines are too long

View File

@ -194,8 +194,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c
**Acceptance criteria** **Acceptance criteria**
- [ ] Completing a quest via objective wiring delivers bundle once. - [x] Completing a quest via objective wiring delivers bundle once.
- [ ] Idempotent complete does not double-grant items or XP. - [x] Idempotent complete does not double-grant items or XP.
**Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06). **Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06).

View File

@ -51,8 +51,15 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Completing a quest via objective wiring delivers bundle once. - [x] Completing a quest via objective wiring delivers bundle once.
- [ ] Idempotent complete replay does not double-grant items or XP. - [x] Idempotent complete replay does not double-grant items or XP.
## Implementation reconciliation (shipped)
- **Operations:** `QuestStateOperations.TryMarkComplete` — deliver-then-mark via `RewardRouterOperations`; idempotent `completed` skip; deny passthrough from `RewardDeliveryReasonCodes`.
- **Wiring:** `QuestObjectiveWiring` + `GatherOperations` / `CraftOperations` / `EncounterCompletionOperations` / quest HTTP APIs thread reward-router dependencies.
- **Tests:** `QuestStateOperationsTests` — bundle delivery, idempotent replay, inventory-full deny; `QuestObjectiveWiringTests` — gather-intro wiring + replay no double-grant.
- **Docs:** `server/README.md`; E7.M2 module anchor + alignment register updated.
## Technical approach ## Technical approach

View File

@ -3,6 +3,7 @@ using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -35,6 +36,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -79,6 +82,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -114,6 +119,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -151,6 +158,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -185,6 +194,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -220,6 +231,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -252,6 +265,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -280,6 +295,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -311,6 +328,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -344,6 +363,8 @@ public sealed class CraftOperationsTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -392,7 +413,9 @@ public sealed class CraftOperationsTests
services.GetRequiredService<ISkillLevelCurve>(), services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(), services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IQuestDefinitionRegistry>(), services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>()); services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
} }
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -429,7 +452,9 @@ public sealed class CraftOperationsTests
ISkillLevelCurve LevelCurve, ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine, PerkUnlockEngine PerkUnlockEngine,
IQuestDefinitionRegistry QuestRegistry, IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore); IPlayerQuestStateStore ProgressStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{ {

View File

@ -1,7 +1,10 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -37,6 +40,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -68,6 +77,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -103,6 +118,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -136,6 +157,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -162,6 +189,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before); deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
@ -180,6 +213,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -212,6 +251,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -253,6 +298,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -291,6 +342,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -313,6 +370,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
} }
@ -363,7 +426,13 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<IItemDefinitionRegistry>(), services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(), services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(), services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>()); services.GetRequiredService<IPlayerQuestStateStore>(),
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) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -389,5 +458,11 @@ public sealed class EncounterCombatWiringTests
IItemDefinitionRegistry ItemRegistry, IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore, IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry, IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore); IPlayerQuestStateStore QuestProgressStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
} }

View File

@ -1,7 +1,10 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -37,6 +40,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider); timeProvider);
// Assert // Assert
@ -80,6 +89,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider); timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
@ -96,6 +111,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider); timeProvider);
// Assert // Assert
@ -154,6 +175,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -188,6 +215,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -224,6 +257,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -258,6 +297,12 @@ public sealed class EncounterCompletionOperationsTests
inventoryStore, inventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -290,6 +335,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.QuestProgressStore, deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
// Assert // Assert
@ -352,7 +403,13 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IItemDefinitionRegistry>(), services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(), services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(), services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>()); services.GetRequiredService<IPlayerQuestStateStore>(),
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) private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
@ -392,7 +449,13 @@ public sealed class EncounterCompletionOperationsTests
IItemDefinitionRegistry ItemRegistry, IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore, IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry, IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore); IPlayerQuestStateStore QuestProgressStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{ {

View File

@ -3,6 +3,7 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -36,6 +37,8 @@ public sealed class GatherOperationsTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -90,6 +93,8 @@ public sealed class GatherOperationsTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -126,6 +131,8 @@ public sealed class GatherOperationsTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
Assert.True(gather.Success); Assert.True(gather.Success);
} }
@ -147,6 +154,8 @@ public sealed class GatherOperationsTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -182,6 +191,8 @@ public sealed class GatherOperationsTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _); var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _);
@ -215,6 +226,8 @@ public sealed class GatherOperationsTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
@ -249,6 +262,8 @@ public sealed class GatherOperationsTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System); TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
@ -273,7 +288,9 @@ public sealed class GatherOperationsTests
services.GetRequiredService<PerkUnlockEngine>(), services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IResourceNodeInstanceStore>(), services.GetRequiredService<IResourceNodeInstanceStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(), services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>()); services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
} }
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId) private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
@ -311,7 +328,9 @@ public sealed class GatherOperationsTests
PerkUnlockEngine PerkUnlockEngine, PerkUnlockEngine PerkUnlockEngine,
IResourceNodeInstanceStore InstanceStore, IResourceNodeInstanceStore InstanceStore,
IQuestDefinitionRegistry QuestRegistry, IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore); IPlayerQuestStateStore ProgressStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
{ {

View File

@ -4,7 +4,10 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -202,22 +205,45 @@ public sealed class QuestAcceptApiTests
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
var questRegistry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>(); var services = factory.Services;
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>(); var questRegistry = services.GetRequiredService<IQuestDefinitionRegistry>();
var progressStore = services.GetRequiredService<IPlayerQuestStateStore>();
var inventoryStore = services.GetRequiredService<IPlayerInventoryStore>();
var itemRegistry = services.GetRequiredService<IItemDefinitionRegistry>();
var skillRegistry = services.GetRequiredService<ISkillDefinitionRegistry>();
var skillProgressionStore = services.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = services.GetRequiredService<ISkillLevelCurve>();
var perkUnlockEngine = services.GetRequiredService<PerkUnlockEngine>();
var perkStore = services.GetRequiredService<IPlayerPerkStateStore>();
var deliveryStore = services.GetRequiredService<IRewardDeliveryStore>();
var timeProvider = TimeProvider.System; var timeProvider = TimeProvider.System;
Assert.True(QuestStateOperations.TryAccept( Assert.True(QuestStateOperations.TryAccept(
PlayerId, PlayerId,
GatherQuestId, GatherQuestId,
questRegistry, questRegistry,
progressStore, progressStore,
factory.Services.GetRequiredService<IPlayerInventoryStore>(), inventoryStore,
factory.Services.GetRequiredService<IItemDefinitionRegistry>(), itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider).Success); timeProvider).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId, PlayerId,
GatherQuestId, GatherQuestId,
questRegistry, questRegistry,
progressStore, progressStore,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider).Success); timeProvider).Success);
// Act // Act

View File

@ -5,6 +5,7 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -41,6 +42,37 @@ public sealed class QuestObjectiveWiringTests
// Assert // Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress)); Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status); Assert.Equal(QuestProgressStatus.Completed, progress.Status);
Assert.Equal(
3 * GatherSkillXpConstants.ActivityXpPerResourceNodeGather + 25,
deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent));
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal(25, deliveryEvent.GrantedSkillXp[0].Amount);
}
[Fact]
public async Task GatherIntro_ShouldNotDoubleGrantXp_WhenCompleteReplayed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
for (var i = 0; i < 3; i++)
{
Assert.True(TryGather(deps, AlphaNodeId).Success);
}
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var firstEvent));
var salvageXpBeforeReplay = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
// Act
var replay = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(replay.Success);
Assert.Equal(salvageXpBeforeReplay, deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var replayEvent));
Assert.Equal(firstEvent.IdempotencyKey, replayEvent.IdempotencyKey);
} }
[Fact] [Fact]
@ -78,6 +110,12 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore, deps.ProgressStore,
deps.InventoryStore, deps.InventoryStore,
deps.ItemRegistry, deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
// Assert // Assert
@ -147,37 +185,13 @@ public sealed class QuestObjectiveWiringTests
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1); SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
Assert.True(TryAccept(deps, ChainQuestId).Success); Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _)); Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
Assert.True(QuestStateOperations.TryAdvanceStep( Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success);
PlayerId,
ChainQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _)); Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
Assert.True(QuestStateOperations.TryAdvanceStep( Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success);
PlayerId,
ChainQuestId,
2,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _)); Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
// Act // Act
var advance = QuestStateOperations.TryAdvanceStep( var advance = TryAdvanceStep(deps, ChainQuestId, 3);
PlayerId,
ChainQuestId,
3,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider);
// Assert // Assert
Assert.True(advance.Success); Assert.True(advance.Success);
@ -201,6 +215,12 @@ public sealed class QuestObjectiveWiringTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
Assert.True(result.Success); Assert.True(result.Success);
} }
@ -232,17 +252,51 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore, deps.ProgressStore,
deps.InventoryStore, deps.InventoryStore,
deps.ItemRegistry, deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestWiringTestDependencies deps, string questId, int step) =>
QuestStateOperations.TryAdvanceStep(
PlayerId,
questId,
step,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
{ {
Assert.True(TryAccept(deps, questId).Success); Assert.True(TryAccept(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(TryMarkComplete(deps, questId).Success);
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider).Success);
} }
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) => private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
@ -259,6 +313,8 @@ public sealed class QuestObjectiveWiringTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -275,6 +331,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
@ -308,6 +366,8 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IEncounterProgressStore>(), services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(), services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(), services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -328,5 +388,7 @@ public sealed class QuestObjectiveWiringTests
IEncounterProgressStore EncounterProgressStore, IEncounterProgressStore EncounterProgressStore,
IEncounterCompletionStore CompletionStore, IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore, IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -7,6 +7,7 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -230,6 +231,12 @@ public sealed class QuestProgressApiTests
deps.InventoryStore, deps.InventoryStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
Assert.True(result.Success); Assert.True(result.Success);
} }
@ -261,17 +268,34 @@ public sealed class QuestProgressApiTests
deps.ProgressStore, deps.ProgressStore,
deps.InventoryStore, deps.InventoryStore,
deps.ItemRegistry, deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryMarkComplete(QuestWiringTestDependencies deps, string questId) =>
QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId) private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
{ {
Assert.True(TryAccept(deps, questId).Success); Assert.True(TryAccept(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(TryMarkComplete(deps, questId).Success);
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider).Success);
} }
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) => private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
@ -288,6 +312,8 @@ public sealed class QuestProgressApiTests
deps.InstanceStore, deps.InstanceStore,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) => private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -304,6 +330,8 @@ public sealed class QuestProgressApiTests
deps.PerkUnlockEngine, deps.PerkUnlockEngine,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity) private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
@ -337,6 +365,8 @@ public sealed class QuestProgressApiTests
services.GetRequiredService<IEncounterProgressStore>(), services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(), services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(), services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -357,5 +387,7 @@ public sealed class QuestProgressApiTests
IEncounterProgressStore EncounterProgressStore, IEncounterProgressStore EncounterProgressStore,
IEncounterCompletionStore CompletionStore, IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore, IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -1,7 +1,10 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -59,12 +62,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory); var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System; var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act // Act
var result = TryAccept(deps, RefineQuestId); var result = TryAccept(deps, RefineQuestId);
@ -116,12 +114,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory); var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System; var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act // Act
var result = TryAccept(deps, GatherQuestId); var result = TryAccept(deps, GatherQuestId);
@ -230,12 +223,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory); var deps = ResolveDependencies(factory);
// Act // Act
var result = QuestStateOperations.TryMarkComplete( var result = TryMarkComplete(deps, "prototype_quest_missing");
PlayerId,
"prototype_quest_missing",
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert // Assert
Assert.False(result.Success); Assert.False(result.Success);
@ -265,12 +253,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory); var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System; var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
// Act // Act
var result = TryAdvanceStep(deps, GatherQuestId, 1); var result = TryAdvanceStep(deps, GatherQuestId, 1);
@ -280,28 +263,83 @@ public sealed class QuestStateOperationsTests
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode); Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode);
} }
[Fact]
public async Task TryMarkComplete_ShouldDeliverGatherIntroBundle_WhenFirstCompletion()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
var result = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent));
Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId);
Assert.Equal(25, deliveryEvent.GrantedSkillXp[0].Amount);
}
[Fact]
public async Task TryMarkComplete_ShouldNotDoubleGrant_WhenIdempotentReplay()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var firstEvent));
// Act
var replay = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(replay.Success);
Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var replayEvent));
Assert.Equal(firstEvent.IdempotencyKey, replayEvent.IdempotencyKey);
Assert.Equal(firstEvent.DeliveredAt, replayEvent.DeliveredAt);
}
[Fact]
public async Task TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndComplete(deps, GatherQuestId);
AcceptAndComplete(deps, RefineQuestId);
AcceptAndComplete(deps, CombatQuestId);
Assert.True(TryAccept(deps, ChainQuestId).Success);
FillBag(deps);
// Act
var result = TryMarkComplete(deps, ChainQuestId);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Active, progress.Status);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out _));
}
[Fact] [Fact]
public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer() public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory); var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act // Act
var first = QuestStateOperations.TryMarkComplete( var first = TryMarkComplete(deps, GatherQuestId);
PlayerId, var second = TryMarkComplete(deps, GatherQuestId);
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
var second = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
// Assert // Assert
Assert.True(first.Success); Assert.True(first.Success);
@ -319,15 +357,9 @@ public sealed class QuestStateOperationsTests
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory); var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
// Act // Act
var result = QuestStateOperations.TryMarkComplete( var result = TryMarkComplete(deps, GatherQuestId);
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
// Assert // Assert
Assert.False(result.Success); Assert.False(result.Success);
@ -343,12 +375,7 @@ public sealed class QuestStateOperationsTests
// Act // Act
var accept = TryAccept(deps, GatherQuestId); var accept = TryAccept(deps, GatherQuestId);
var complete = QuestStateOperations.TryMarkComplete( var complete = TryMarkComplete(deps, GatherQuestId);
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert // Assert
Assert.True(accept.Success); Assert.True(accept.Success);
@ -380,16 +407,41 @@ public sealed class QuestStateOperationsTests
Assert.True(canWrite); Assert.True(canWrite);
} }
private static void FillBag(QuestTestDependencies 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 void AcceptAndComplete(QuestTestDependencies deps, string questId) private static void AcceptAndComplete(QuestTestDependencies deps, string questId)
{ {
Assert.True(TryAccept(deps, questId).Success); Assert.True(TryAccept(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete( Assert.True(TryMarkComplete(deps, questId).Success);
}
private static QuestStateOperationResult TryMarkComplete(QuestTestDependencies deps, string questId) =>
QuestStateOperations.TryMarkComplete(
PlayerId, PlayerId,
questId, questId,
deps.QuestRegistry, deps.QuestRegistry,
deps.ProgressStore, deps.ProgressStore,
deps.TimeProvider).Success); deps.ItemRegistry,
} deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) => private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) =>
TryAccept(deps, PlayerId, questId); TryAccept(deps, PlayerId, questId);
@ -402,6 +454,12 @@ public sealed class QuestStateOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.InventoryStore, deps.InventoryStore,
deps.ItemRegistry, deps.ItemRegistry,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -413,6 +471,12 @@ public sealed class QuestStateOperationsTests
deps.ProgressStore, deps.ProgressStore,
deps.InventoryStore, deps.InventoryStore,
deps.ItemRegistry, deps.ItemRegistry,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider); deps.TimeProvider);
private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
@ -423,6 +487,12 @@ public sealed class QuestStateOperationsTests
services.GetRequiredService<IPlayerQuestStateStore>(), services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(), services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(), services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System); TimeProvider.System);
} }
@ -431,5 +501,11 @@ public sealed class QuestStateOperationsTests
IPlayerQuestStateStore ProgressStore, IPlayerQuestStateStore ProgressStore,
IPlayerInventoryStore InventoryStore, IPlayerInventoryStore InventoryStore,
IItemDefinitionRegistry ItemRegistry, IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider); TimeProvider TimeProvider);
} }

View File

@ -3,9 +3,12 @@ using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World; using NeonSprawl.Server.Game.World;
@ -81,6 +84,12 @@ public static class AbilityCastApi
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore, IPlayerQuestStateStore questProgressStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
TimeProvider clock) => TimeProvider clock) =>
{ {
@ -282,6 +291,12 @@ public static class AbilityCastApi
inventoryStore, inventoryStore,
questRegistry, questRegistry,
questProgressStore, questProgressStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
clock, clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring))); loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));

View File

@ -1,6 +1,7 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Crafting; namespace NeonSprawl.Server.Game.Crafting;
@ -30,6 +31,8 @@ public static class CraftOperations
PerkUnlockEngine perkUnlockEngine, PerkUnlockEngine perkUnlockEngine,
IQuestDefinitionRegistry questRegistry, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (quantity <= 0) if (quantity <= 0)
@ -165,6 +168,12 @@ public static class CraftOperations
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
xpStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
return new CraftResult( return new CraftResult(

View File

@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Crafting; namespace NeonSprawl.Server.Game.Crafting;
@ -18,7 +19,8 @@ public static class PlayerCraftApi
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry, PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) => IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
{ {
if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion)
{ {
@ -50,6 +52,8 @@ public static class PlayerCraftApi
perkUnlockEngine, perkUnlockEngine,
questRegistry, questRegistry,
progressStore, progressStore,
perkStore,
deliveryStore,
timeProvider); timeProvider);
if (!result.Success && if (!result.Success &&

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Game.Encounters;
@ -26,6 +29,12 @@ public static class EncounterCombatWiring
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore, IPlayerQuestStateStore questProgressStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -84,6 +93,12 @@ public static class EncounterCombatWiring
inventoryStore, inventoryStore,
questRegistry, questRegistry,
questProgressStore, questProgressStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
if (!completion.Success) if (!completion.Success)

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Encounters; namespace NeonSprawl.Server.Game.Encounters;
@ -29,6 +32,12 @@ public static class EncounterCompletionOperations
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore, IPlayerQuestStateStore questProgressStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) || if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
@ -145,6 +154,12 @@ public static class EncounterCompletionOperations
questProgressStore, questProgressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
return new EncounterCompletionResult( return new EncounterCompletionResult(

View File

@ -1,6 +1,7 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Gathering; namespace NeonSprawl.Server.Game.Gathering;
@ -31,6 +32,8 @@ public static class GatherOperations
IResourceNodeInstanceStore instanceStore, IResourceNodeInstanceStore instanceStore,
IQuestDefinitionRegistry questRegistry, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
var key = ResourceNodeInstanceIds.Normalize(interactableId); var key = ResourceNodeInstanceIds.Normalize(interactableId);
@ -140,6 +143,12 @@ public static class GatherOperations
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
xpStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
return new GatherResult( return new GatherResult(

View File

@ -3,6 +3,7 @@ using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World; using NeonSprawl.Server.Game.World;
@ -23,7 +24,8 @@ public static class InteractionApi
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry, IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry, IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) => IPlayerQuestStateStore progressStore, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
{ {
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{ {
@ -91,6 +93,8 @@ public static class InteractionApi
nodeInstanceStore, nodeInstanceStore,
questRegistry, questRegistry,
progressStore, progressStore,
perkStore,
deliveryStore,
timeProvider); timeProvider);
if (!gather.Success) if (!gather.Success)

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
@ -13,7 +16,9 @@ public static class QuestAcceptApi
(string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions, (string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore, IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry, IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider) => ISkillDefinitionRegistry skillRegistry, IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider) =>
{ {
if (body is not null && if (body is not null &&
body.SchemaVersion != 0 && body.SchemaVersion != 0 &&
@ -35,6 +40,12 @@ public static class QuestAcceptApi
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
return Results.Json(MapResponse(result)); return Results.Json(MapResponse(result));

View File

@ -1,4 +1,7 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
@ -19,6 +22,12 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -59,6 +68,12 @@ public static class QuestObjectiveWiring
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -76,6 +91,12 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -116,6 +137,12 @@ public static class QuestObjectiveWiring
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -132,6 +159,12 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -171,6 +204,12 @@ public static class QuestObjectiveWiring
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -209,6 +248,12 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider, TimeProvider timeProvider,
ILogger? logger = null) ILogger? logger = null)
{ {
@ -227,6 +272,12 @@ public static class QuestObjectiveWiring
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
} }
catch (Exception ex) catch (Exception ex)
@ -286,6 +337,12 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++) for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++)
@ -316,6 +373,14 @@ public static class QuestObjectiveWiring
questId, questId,
questRegistry, questRegistry,
progressStore, progressStore,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
return; return;
} }
@ -328,6 +393,12 @@ public static class QuestObjectiveWiring
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
if (!advance.Success) if (!advance.Success)
@ -343,6 +414,12 @@ public static class QuestObjectiveWiring
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
foreach (var definition in questRegistry.GetDefinitionsInIdOrder()) foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
@ -356,6 +433,12 @@ public static class QuestObjectiveWiring
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
} }
} }

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
@ -16,7 +19,10 @@ public static class QuestProgressApi
"/game/players/{id}/quest-progress", "/game/players/{id}/quest-progress",
(string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry, (string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore, IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, TimeProvider timeProvider, ILogger<Program>? logger) => IItemDefinitionRegistry itemRegistry, ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine, IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore, TimeProvider timeProvider, ILogger<Program>? logger) =>
{ {
var trimmedId = id.Trim(); var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
@ -30,6 +36,12 @@ public static class QuestProgressApi
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider, timeProvider,
logger); logger);

View File

@ -1,4 +1,7 @@
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
@ -20,6 +23,12 @@ public static class QuestStateOperations
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -73,6 +82,12 @@ public static class QuestStateOperations
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -97,6 +112,12 @@ public static class QuestStateOperations
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -146,6 +167,12 @@ public static class QuestStateOperations
progressStore, progressStore,
inventoryStore, inventoryStore,
itemRegistry, itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider); timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed)) if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -160,17 +187,26 @@ public static class QuestStateOperations
} }
/// <summary> /// <summary>
/// Marks a quest complete. Idempotent: replays return success with the existing snapshot. /// Delivers <see cref="QuestDefRow.CompletionRewardBundle"/> then marks complete (NEO-128).
/// Idempotent: replays return success with the existing snapshot without re-delivering.
/// </summary> /// </summary>
public static QuestStateOperationResult TryMarkComplete( public static QuestStateOperationResult TryMarkComplete(
string playerId, string playerId,
string questId, string questId,
IQuestDefinitionRegistry questRegistry, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) || if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out _)) !questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
{ {
return Deny(QuestStateReasonCodes.UnknownQuest); return Deny(QuestStateReasonCodes.UnknownQuest);
} }
@ -190,6 +226,25 @@ public static class QuestStateOperations
return Deny(QuestStateReasonCodes.NotActive); return Deny(QuestStateReasonCodes.NotActive);
} }
var delivery = RewardRouterOperations.TryDeliverQuestCompletion(
playerId,
normalizedQuestId,
definition.CompletionRewardBundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (!delivery.Success)
{
return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidQuestId, progress);
}
if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot)) if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot))
{ {
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_complete` --- // --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_complete` ---

View File

@ -183,7 +183,7 @@ Completed rows cannot regress to active without a reset API (none in prototype).
|--------|------| |--------|------|
| **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. | | **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. |
| **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. | | **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. |
| **`TryMarkComplete`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). | | **`TryMarkComplete`** | **NEO-128:** delivers **`completionRewardBundle`** via **`RewardRouterOperations`** (deliver-then-mark), then marks active quest complete. **Idempotent:** when already **completed**, returns **`success: true`** without calling the router; replay after first-time complete also skips re-delivery. On delivery deny, quest stays **active** and **`reasonCode`** passthroughs router codes (e.g. **`inventory_full`**). |
**Reason codes:** **Reason codes:**
@ -297,7 +297,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. | | **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. |
| **`progression_store_missing`** | Skill progression store could not apply XP delta. | | **`progression_store_missing`** | Skill progression store could not apply XP delta. |
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). 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 (NEO-128):** **`QuestStateOperations.TryMarkComplete`** and **`QuestObjectiveWiring`** terminal-step completion call this operation before **`IPlayerQuestStateStore.TryMarkComplete`**. Plans: [NEO-127](../../docs/plans/NEO-127-implementation-plan.md), [NEO-128](../../docs/plans/NEO-128-implementation-plan.md).
## Encounter definitions (NEO-103) ## Encounter definitions (NEO-103)