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-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**
- [ ] Completing a quest via objective wiring delivers bundle once.
- [ ] Idempotent complete does not double-grant items or XP.
- [x] Completing a quest via objective wiring delivers bundle once.
- [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).

View File

@ -51,8 +51,15 @@
## Acceptance criteria checklist
- [ ] Completing a quest via objective wiring delivers bundle once.
- [ ] Idempotent complete replay does not double-grant items or XP.
- [x] Completing a quest via objective wiring delivers bundle once.
- [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

View File

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

View File

@ -1,7 +1,10 @@
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;
@ -37,6 +40,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -68,6 +77,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -103,6 +118,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -136,6 +157,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -162,6 +189,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
@ -180,6 +213,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -212,6 +251,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -253,6 +298,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -291,6 +342,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -313,6 +370,12 @@ public sealed class EncounterCombatWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
}
@ -363,7 +426,13 @@ public sealed class EncounterCombatWiringTests
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
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)
@ -389,5 +458,11 @@ public sealed class EncounterCombatWiringTests
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
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 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;
@ -37,6 +40,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
// Assert
@ -80,6 +89,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
@ -96,6 +111,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
// Assert
@ -154,6 +175,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -188,6 +215,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -224,6 +257,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -258,6 +297,12 @@ public sealed class EncounterCompletionOperationsTests
inventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -290,6 +335,12 @@ public sealed class EncounterCompletionOperationsTests
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
@ -352,7 +403,13 @@ public sealed class EncounterCompletionOperationsTests
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
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)
@ -392,7 +449,13 @@ public sealed class EncounterCompletionOperationsTests
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
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
{

View File

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

View File

@ -4,7 +4,10 @@ using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
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;
@ -202,22 +205,45 @@ public sealed class QuestAcceptApiTests
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var questRegistry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
var services = factory.Services;
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;
Assert.True(QuestStateOperations.TryAccept(
PlayerId,
GatherQuestId,
questRegistry,
progressStore,
factory.Services.GetRequiredService<IPlayerInventoryStore>(),
factory.Services.GetRequiredService<IItemDefinitionRegistry>(),
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
questRegistry,
progressStore,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider).Success);
// Act

View File

@ -5,6 +5,7 @@ using NeonSprawl.Server.Game.Gathering;
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;
@ -41,6 +42,37 @@ public sealed class QuestObjectiveWiringTests
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
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]
@ -78,6 +110,12 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
// Assert
@ -147,37 +185,13 @@ public sealed class QuestObjectiveWiringTests
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
Assert.True(QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
1,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider).Success);
Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
Assert.True(QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
2,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider).Success);
Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
// Act
var advance = QuestStateOperations.TryAdvanceStep(
PlayerId,
ChainQuestId,
3,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider);
var advance = TryAdvanceStep(deps, ChainQuestId, 3);
// Assert
Assert.True(advance.Success);
@ -201,6 +215,12 @@ public sealed class QuestObjectiveWiringTests
deps.InventoryStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
Assert.True(result.Success);
}
@ -232,17 +252,51 @@ public sealed class QuestObjectiveWiringTests
deps.ProgressStore,
deps.InventoryStore,
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);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
{
Assert.True(TryAccept(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider).Success);
Assert.True(TryMarkComplete(deps, questId).Success);
}
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
@ -259,6 +313,8 @@ public sealed class QuestObjectiveWiringTests
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
@ -275,6 +331,8 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
@ -308,6 +366,8 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System);
}
@ -328,5 +388,7 @@ public sealed class QuestObjectiveWiringTests
IEncounterProgressStore EncounterProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider);
}

View File

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

View File

@ -1,7 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
@ -59,12 +62,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
// Act
var result = TryAccept(deps, RefineQuestId);
@ -116,12 +114,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
// Act
var result = TryAccept(deps, GatherQuestId);
@ -230,12 +223,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryMarkComplete(
PlayerId,
"prototype_quest_missing",
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
var result = TryMarkComplete(deps, "prototype_quest_missing");
// Assert
Assert.False(result.Success);
@ -265,12 +253,7 @@ public sealed class QuestStateOperationsTests
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider).Success);
Assert.True(TryMarkComplete(deps, GatherQuestId).Success);
// Act
var result = TryAdvanceStep(deps, GatherQuestId, 1);
@ -280,28 +263,83 @@ public sealed class QuestStateOperationsTests
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]
public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
Assert.True(TryAccept(deps, GatherQuestId).Success);
// Act
var first = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
var second = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
var first = TryMarkComplete(deps, GatherQuestId);
var second = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(first.Success);
@ -319,15 +357,9 @@ public sealed class QuestStateOperationsTests
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var timeProvider = TimeProvider.System;
// Act
var result = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
timeProvider);
var result = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.False(result.Success);
@ -343,12 +375,7 @@ public sealed class QuestStateOperationsTests
// Act
var accept = TryAccept(deps, GatherQuestId);
var complete = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
var complete = TryMarkComplete(deps, GatherQuestId);
// Assert
Assert.True(accept.Success);
@ -380,16 +407,41 @@ public sealed class QuestStateOperationsTests
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)
{
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,
questId,
deps.QuestRegistry,
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) =>
TryAccept(deps, PlayerId, questId);
@ -402,6 +454,12 @@ public sealed class QuestStateOperationsTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
@ -413,6 +471,12 @@ public sealed class QuestStateOperationsTests
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
@ -423,6 +487,12 @@ public sealed class QuestStateOperationsTests
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System);
}
@ -431,5 +501,11 @@ public sealed class QuestStateOperationsTests
IPlayerQuestStateStore ProgressStore,
IPlayerInventoryStore InventoryStore,
IItemDefinitionRegistry ItemRegistry,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
TimeProvider TimeProvider);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
@ -13,7 +16,9 @@ public static class QuestAcceptApi
(string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
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 &&
body.SchemaVersion != 0 &&
@ -35,6 +40,12 @@ public static class QuestAcceptApi
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
return Results.Json(MapResponse(result));

View File

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

View File

@ -1,5 +1,8 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
@ -16,7 +19,10 @@ public static class QuestProgressApi
"/game/players/{id}/quest-progress",
(string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry,
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();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
@ -30,6 +36,12 @@ public static class QuestProgressApi
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider,
logger);

View File

@ -1,4 +1,7 @@
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;
@ -20,6 +23,12 @@ public static class QuestStateOperations
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -73,6 +82,12 @@ public static class QuestStateOperations
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -97,6 +112,12 @@ public static class QuestStateOperations
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
@ -146,6 +167,12 @@ public static class QuestStateOperations
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
@ -160,17 +187,26 @@ public static class QuestStateOperations
}
/// <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>
public static QuestStateOperationResult TryMarkComplete(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out _))
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
@ -190,6 +226,25 @@ public static class QuestStateOperations
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))
{
// --- 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. |
| **`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:**
@ -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. |
| **`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)