Merge pull request #167 from ViPro-Technologies/NEO-128-e7m2-quest-complete-reward-wiring

NEO-128: Wire quest complete to reward router (deliver-then-mark)
pull/168/head
VinPropane 2026-06-07 18:35:13 -04:00 committed by GitHub
commit c1849740b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1099 additions and 115 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

@ -0,0 +1,165 @@
# NEO-128 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-128 |
| **Title** | E7M2-05: Wire router into `QuestStateOperations.TryMarkComplete` |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-128/e7m2-05-wire-router-into-queststateoperationstrymarkcomplete |
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-05** |
| **Branch** | `NEO-128-e7m2-quest-complete-reward-wiring` |
| **Precursor** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) **`Done`** — `RewardRouterOperations.TryDeliverQuestCompletion` |
| **Pattern** | [NEO-105](NEO-105-implementation-plan.md) — deliver side effects before completion mark; [NEO-118](NEO-118-implementation-plan.md) — `QuestObjectiveWiring``TryMarkComplete` on terminal step |
| **Blocks** | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) (`completionRewardSummary` HTTP), [NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) (telemetry hook comments) |
| **Client counterpart** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06 / NEO-129) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Delivery vs mark ordering** | When should bundle apply run relative to `TryMarkComplete`? | **Deliver bundle first, then mark complete** — E7M2 “transactional equivalent” to fail-closed; mirrors NEO-105 (grants before completion mark); avoids new `TryRevertCompletion` store API. | **Adopted** — deliver-then-mark |
| **Bruno smoke** | Add Bruno requests for quest-complete reward delivery? | **Skip** — server-internal wiring only; no HTTP DTO changes (NEO-129); automated integration tests cover AC; Bruno optional per backlog because verification is store/inventory/skill assertions, not a new route. | **Adopted** — skip Bruno (functional smoke); **`GET /health`** Bruno added only for pre-commit hook when `*Api.cs` DI signatures changed — bundle asserts remain in `dotnet test`. |
**Additional defaults (no kickoff question — settled by backlog / landed code):**
- **Idempotent quest replay** — when progress is already **`completed`**, return success immediately and **do not** call the router (NEO-121 `quest_complete` hook path unchanged; no double-grant).
- **Deny reason passthrough** — on delivery deny, return **`QuestStateOperationResult`** with **`Success: false`** and **`ReasonCode`** from **`RewardDeliveryResult.ReasonCode`** (`inventory_full`, etc.); quest row stays **`active`**.
- **`QuestStateOperationResult` shape** — unchanged; HTTP projection of grants is NEO-129.
- **Bundle source**`definition.CompletionRewardBundle` from **`IQuestDefinitionRegistry`**; all four prototype quests have bundles (NEO-124/125 CI gates).
- **Wiring best-effort**`QuestObjectiveWiring` continues to ignore `TryMarkComplete` deny envelopes (primary gather/craft/encounter ops unaffected); on inventory-full deny the quest remains active for retry.
## Goal, scope, and out-of-scope
**Goal:** First-time quest completion triggers **`completionRewardBundle`** delivery exactly once via **`RewardRouterOperations`**; idempotent complete replay does not double-grant items or XP.
**In scope (from Linear + [E7M2-05](E7M2-prototype-backlog.md#e7m2-05--wire-router-into-queststateoperationstrymarkcomplete)):**
- Call **`RewardRouterOperations.TryDeliverQuestCompletion`** from **`QuestStateOperations.TryMarkComplete`** on the first-time completion path (before store mark per adopted ordering).
- Skip router on idempotent **`completed`** early return.
- Thread router dependencies through **`QuestObjectiveWiring`** and upstream ops (`GatherOperations`, `CraftOperations`, `EncounterCompletionOperations`, `QuestProgressApi`) so objective-wiring completion exercises the same path.
- Integration tests (AAA): gather-intro wiring → salvage +25 + store record; idempotent `TryMarkComplete` replay → no duplicate XP; inventory-full deny → quest stays active, no store record; operator-chain → item + skill grants.
- `server/README.md` quest-state section update; E7.M2 module alignment anchor.
**Out of scope (from Linear):**
- HTTP **`completionRewardSummary`** ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06).
- E9.M1 telemetry ingest ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) E7M2-07) — existing comment-only hook sites stay.
- Encounter completion path; per-step bundles.
- Bruno smoke (deferred per kickoff).
- Godot client.
## Acceptance criteria checklist
- [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` — gather + operator-chain bundle delivery, idempotent replay, inventory-full deny; `QuestObjectiveWiringTests` — gather-intro wiring + replay no double-grant; operator-chain wiring happy path (`survey_drone_kit` + salvage +50); wiring inventory-full stay-active on terminal advance.
- **Docs:** `server/README.md`; E7.M2 module anchor + alignment register updated.
## Technical approach
### 1. Extend `TryMarkComplete` (deliver-then-mark)
Add router dependencies to the static signature (mirror **`RewardRouterOperations.TryDeliverQuestCompletion`** injectables):
```csharp
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)
```
**First-time completion flow** (after existing registry / active guards):
1. Load **`definition.CompletionRewardBundle`** from registry (already resolved in guard).
2. **`RewardRouterOperations.TryDeliverQuestCompletion(...)`** — includes store idempotent replay (`already_delivered` success when store hit).
3. If **`!delivery.Success`**, return **`Deny(delivery.ReasonCode!)`** — quest stays **`active`**.
4. **`progressStore.TryMarkComplete(...)`** — on success, emit existing NEO-121 **`quest_complete`** comment hook; return **`Success(snapshot)`**.
5. Preserve existing idempotent / race handling when **`TryMarkComplete`** returns false but progress is already **`completed`** (re-read paths unchanged).
**Idempotent replay** (progress already **`completed`**): existing early return — **no router call**.
### 2. Dependency threading
| Layer | Change |
|-------|--------|
| **`QuestObjectiveWiring`** | Add reward-router parameters to **`TryCompleteStepIfSatisfied`**, **`TryCompleteAllActiveQuests`**, and all public entry points that call them (`TryProcessGatherSuccess`, `TryProcessCraftSuccess`, `TryProcessEncounterCompleteSuccess`, `TryRefreshInventoryProgressForPlayer`). |
| **`QuestStateOperations`** | Pass new deps into **`TryMarkComplete`** from **`TryAccept`** / **`TryAdvanceStep`** wiring calls. |
| **`GatherOperations`**, **`CraftOperations`**, **`EncounterCompletionOperations`** | Extend method signatures + call sites to pass skill/progress/perk/delivery deps into wiring (resolve from existing HTTP/ops injectables same as gather/craft already thread quest stores). |
| **`QuestProgressApi`** | Pass reward deps into **`TryRefreshInventoryProgressForPlayer`**. |
Use **`InMemoryWebApplicationFactory`** DI resolution in tests — no new service registration (stores already in **`Program.cs`**).
### 3. Tests
| Test file | New / extended cases |
|-----------|---------------------|
| **`QuestStateOperationsTests`** | **`TryMarkComplete_ShouldDeliverGatherIntroBundle_WhenFirstCompletion`** — accept + complete → **`IRewardDeliveryStore.TryGet`** true, salvage XP +25. **`TryMarkComplete_ShouldNotDoubleGrant_WhenIdempotentReplay`** — second complete → same store event, XP unchanged. **`TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull`** — fill bag, complete operator-chain quest setup → deny **`inventory_full`**, status **`active`**, no store record. |
| **`QuestObjectiveWiringTests`** | Extend **`GatherIntro_ShouldComplete_WhenScrapGatheredThreeTimes`** (or sibling) — assert salvage XP +25 and delivery store after wiring-driven complete. **`GatherIntro_ShouldNotDoubleGrantXp_WhenCompleteReplayed`** — manual second **`TryMarkComplete`** after wiring complete. |
Reuse **`RewardRouterTestDependencies`**-style resolution or extend existing **`QuestTestDependencies`** / **`QuestWiringTestDependencies`** records with skill + delivery services.
### 4. Docs
- **`server/README.md`** — update **`QuestStateOperations.TryMarkComplete`** section: deliver-then-mark order, idempotent skip, deny passthrough, pointer to **`RewardRouterOperations`**.
- **`documentation_and_implementation_alignment.md`** — E7.M2 row: quest-state wiring (NEO-128).
- **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor for **`TryMarkComplete`** wiring.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-128-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Wire **`RewardRouterOperations`** into **`TryMarkComplete`**; extend signature; thread deps from accept/advance. |
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Pass reward-router deps through completion wiring chain. |
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Thread reward deps into **`TryProcessGatherSuccess`**. |
| `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | Thread reward deps into **`TryProcessCraftSuccess`**. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Thread reward deps into **`TryProcessEncounterCompleteSuccess`**. |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Thread reward deps into inventory refresh wiring. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Bundle delivery, idempotent replay, inventory-full deny tests. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Wiring path asserts store + skill XP after gather complete. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | Update **`TryMarkComplete`** call sites with new parameters. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Update **`TryMarkComplete`** / wiring dependency resolution. |
| `server/README.md` | Document quest-complete reward wiring on **`TryMarkComplete`**. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — quest-state wiring (NEO-128). |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | NEO-128 implementation anchor. |
## Tests
| Target | Coverage |
|--------|----------|
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Direct **`TryMarkComplete`**: first completion delivers bundle + records store; idempotent replay no double XP; inventory-full deny leaves quest active without store record. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | Gather-intro wiring path delivers salvage +25 once; replay does not double-grant. |
| **Compile-fix only**`QuestAcceptApiTests`, `QuestProgressApiTests` | Updated **`TryMarkComplete`** signatures; existing accept/progress behavior unchanged. |
| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter "QuestStateOperationsTests|QuestObjectiveWiringTests"`; dev server boot unchanged. |
## Open questions / risks
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| Delivery succeeds but **`TryMarkComplete`** store write fails while quest still active | Extremely unlikely solo prototype; handle via existing re-read idempotent paths when another caller completed; defer explicit delivery rollback on mark-fail unless concurrency story requires it. | **deferred** |
| Orphan store record if mark never commits | **`TryDeliver`** idempotent replay on retry allows **`already_delivered`** success then mark on next complete attempt — self-healing. | **adopted** |
| Large signature expansion across wiring call graph | Accept explicit parameter threading (NEO-118 precedent); no new DI facade type in this story. | **adopted** |
| **`QuestStateOperationResult` grant projection** | Defer to NEO-129 HTTP read model. | **deferred** |
None beyond the above.

View File

@ -0,0 +1,60 @@
# Code review — NEO-128 (E7M2-05)
**Date:** 2026-06-07
**Scope:** Branch `NEO-128-e7m2-quest-complete-reward-wiring` vs `origin/main` — commits `7abe7dc``da99769`
**Base:** `origin/main`
## Verdict
**Approve with nits** — deliver-then-mark wiring in `QuestStateOperations.TryMarkComplete`, idempotent skip, deny passthrough, and dependency threading through objective wiring and upstream ops match the adopted plan and NEO-105 precedent; tests cover the primary AC paths; risk is low (server-internal until NEO-129 HTTP projection).
## Summary
NEO-128 wires **`RewardRouterOperations.TryDeliverQuestCompletion`** into **`QuestStateOperations.TryMarkComplete`** on the first-time active→completed path: bundle delivery runs before **`IPlayerQuestStateStore.TryMarkComplete`**, idempotent **`completed`** replay returns early without calling the router, and delivery denies passthrough router **`reasonCode`** values while leaving the quest **active**. Reward-router dependencies are threaded through **`QuestObjectiveWiring`**, **`GatherOperations`**, **`CraftOperations`**, **`EncounterCompletionOperations`**, **`QuestProgressApi`**, **`QuestAcceptApi`**, **`AbilityCastApi`**, and related call sites so wiring-driven completion exercises the same path. Five new/extended AAA integration tests lock gather-intro bundle delivery, idempotent replay (direct and wiring), and inventory-full deny on operator-chain setup; compile-fix updates propagate the expanded signatures across quest/gather/craft/encounter test harnesses. Docs (plan reconciliation, E7.M2 anchor, alignment register, backlog AC, `server/README.md`) are updated. A minimal Bruno health smoke was added (host boot check only).
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-128-implementation-plan.md` | **Matches** — kickoff deliver-then-mark and idempotent skip adopted; reconciliation accurate; AC checked. |
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-05) | **Matches** — AC checkboxes complete; Bruno listed as optional in backlog (health-only smoke landed). |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-128 implementation anchor added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-05 quest-state wiring; row correctly stays **Planned** until NEO-132 capstone. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required. |
| `server/README.md` | **Matches**`TryMarkComplete` table row and reward-router cross-ref document deliver-then-mark, idempotent skip, deny passthrough. |
| Full-stack epic decomposition | **N/A** — E7M2-05 is server engine; client counterpart NEO-131 correctly deferred (blocked by NEO-129). |
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate.
## Blocking issues
None.
## Suggestions
1. ~~**Operator-chain happy-path delivery assertions** — The plan lists integration coverage for “operator-chain → item + skill grants.” `TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull` exercises chain quest at the operations layer (deny only). `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` completes via wiring but does not assert **`IRewardDeliveryStore`**, **`survey_drone_kit`**, or salvage +50. NEO-127 covers router apply in isolation; extending the wiring or operations happy-path test would lock end-to-end **`TryMarkComplete`** + item grant for the only bundle with **`itemGrants`**.~~ **Done.** Added `TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion`; extended `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet` with store, item, and skill XP asserts.
2. ~~**Inventory-full via wiring path** — Deny is tested via direct **`TryMarkComplete`** after manual chain setup. Optional: one wiring-driven test (e.g. fill bag then complete terminal **`inventory_has_item`** step) to prove **`QuestObjectiveWiring`** best-effort ignore does not mask the deny for primary ops while quest stays **active** for retry.~~ **Done.** Added `ChainQuest_ShouldStayActive_WhenBagFullOnTerminalWiringComplete` (terminal advance via `TryAdvanceStep` while bag full; quest stays **active**, no store record).
## Nits
- ~~Nit: Kickoff adopted “skip Bruno” for bundle verification, but a **`GET /health`** smoke was added under `bruno/neon-sprawl-server/quest-progress/` with docs pointing to dotnet test for bundle asserts. Harmless and consistent with backlog “optional” wording; consider a one-line plan note if authors want kickoff vs backlog Bruno policy explicit.~~ **Done.** Plan kickoff clarifications note health-only Bruno for pre-commit hook; functional verification via `dotnet test`.
- Nit: **`TryCompleteStepIfSatisfied`** discards **`TryMarkComplete`** return value (by design per plan — wiring best-effort). Inventory-full deny therefore does not surface on gather/craft/encounter envelopes; acceptable for prototype, worth remembering for NEO-131 client surfacing.
- Nit: Delivery succeeds then **`TryMarkComplete`** store write fails — orphan store record self-heals via **`already_delivered`** on retry (plan deferred). No action required for prototype.
- Nit: Expanded parameter lists on **`TryAccept`** / **`TryAdvanceStep`** mirror NEO-118 threading precedent; a future facade/DI record could reduce call-site churn but is explicitly out of scope.
## Verification
```bash
dotnet test server/NeonSprawl.Server.Tests --filter "QuestStateOperationsTests|QuestObjectiveWiringTests"
dotnet test server/NeonSprawl.Server.Tests --filter "FullyQualifiedName~Quests|FullyQualifiedName~GatherOperations|FullyQualifiedName~CraftOperations|FullyQualifiedName~EncounterCompletion|FullyQualifiedName~EncounterCombatWiring"
```
All 32 quest-state/wiring tests and 141 related tests pass (verified 2026-06-07; +2 after review follow-up).
Optional before merge:
- Boot dev server; run Bruno `GET health (quest complete reward wiring NEO-128)` or `curl /health` — no new DI registration beyond existing reward stores.

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
@ -110,12 +148,56 @@ public sealed class QuestObjectiveWiringTests
// Act — step 2: field stim craft
SeedStack(deps, "refined_plate_stock", 2);
SeedStack(deps, "scrap_metal_bulk", 1);
var salvageXpBeforeComplete = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var kitBefore = CountBagItem(beforeInventory!, "survey_drone_kit");
Assert.True(TryCraft(deps, "make_field_stim_mk0").Success);
// Assert — terminal inventory_has_item (contract_handoff_token from combat intro)
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var completed));
Assert.Equal(QuestProgressStatus.Completed, completed.Status);
Assert.Equal(3, completed.CurrentStepIndex);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out var deliveryEvent));
Assert.Single(deliveryEvent.GrantedItems);
Assert.Equal("survey_drone_kit", deliveryEvent.GrantedItems[0].ItemId);
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount);
Assert.Equal(salvageXpBeforeComplete + 50, deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(kitBefore + 1, CountBagItem(afterInventory!, "survey_drone_kit"));
}
[Fact]
public async Task ChainQuest_ShouldStayActive_WhenBagFullOnTerminalWiringComplete()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
AcceptAndMarkComplete(deps, GatherQuestId);
AcceptAndMarkComplete(deps, RefineQuestId);
AcceptAndMarkComplete(deps, CombatQuestId);
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
Assert.True(TryAdvanceStep(deps, ChainQuestId, 1).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
Assert.True(TryAdvanceStep(deps, ChainQuestId, 2).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
FillRemainingBagSlots(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
var kitBefore = CountBagItem(beforeInventory!, "survey_drone_kit");
// Act
var advance = TryAdvanceStep(deps, ChainQuestId, 3);
// Assert
Assert.True(advance.Success);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Active, progress.Status);
Assert.Equal(3, progress.CurrentStepIndex);
Assert.False(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out _));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(kitBefore, CountBagItem(afterInventory!, "survey_drone_kit"));
}
[Fact]
@ -147,37 +229,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 +259,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 +296,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 +357,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,8 +375,40 @@ public sealed class QuestObjectiveWiringTests
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.PerkStore,
deps.DeliveryStore,
deps.TimeProvider);
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static void FillRemainingBagSlots(QuestWiringTestDependencies deps)
{
deps.InventoryStore.TryGetSnapshot(PlayerId, out var snapshot);
var emptySlots = snapshot!.BagSlots.Count(static slot => slot.IsEmpty);
for (var i = 0; i < emptySlots; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
PlayerId,
"survey_drone_kit",
quantity: 1,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
}
}
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
{
var outcome = PlayerInventoryOperations.TryAddStack(
@ -308,6 +440,8 @@ public sealed class QuestObjectiveWiringTests
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
TimeProvider.System);
}
@ -328,5 +462,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,114 @@ 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_ShouldDeliverOperatorChainBundle_WhenFirstCompletion()
{
// 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);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = TryMarkComplete(deps, ChainQuestId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status);
Assert.True(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out var deliveryEvent));
Assert.Single(deliveryEvent.GrantedItems);
Assert.Equal("survey_drone_kit", deliveryEvent.GrantedItems[0].ItemId);
Assert.Equal(1, deliveryEvent.GrantedItems[0].Quantity);
Assert.Single(deliveryEvent.GrantedSkillXp);
Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId);
Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit"));
Assert.Equal(100, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
}
[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 +388,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 +406,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 +438,55 @@ public sealed class QuestStateOperationsTests
Assert.True(canWrite);
}
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
{
total += slot.Quantity;
}
}
return total;
}
private static 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 +499,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 +516,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 +532,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 +546,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)