Merge pull request #177 from ViPro-Technologies/chore/bruno-self-reset-fixtures
chore: Bruno self-reset dev fixtures for order-independent collection runspull/180/head
commit
a70c41db5a
|
|
@ -94,6 +94,7 @@ jobs:
|
||||||
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
|
Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev
|
||||||
Game__EnableCombatTargetFixtureApi: "true"
|
Game__EnableCombatTargetFixtureApi: "true"
|
||||||
Game__EnableQuestFixtureApi: "true"
|
Game__EnableQuestFixtureApi: "true"
|
||||||
|
Game__EnableResourceNodeFixtureApi: "true"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \
|
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ meta {
|
||||||
}
|
}
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
|
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
|
||||||
|
await resetPrototypeCombatTargets(bru);
|
||||||
|
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ docs {
|
||||||
}
|
}
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
|
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
|
||||||
|
await resetPrototypeCombatTargets(bru);
|
||||||
|
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,23 @@ docs {
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
const { resetGatherIntroQuestProgress } = require("./scripts/bruno-dev-fixture-helper");
|
||||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||||
await axios.post(
|
|
||||||
|
await resetGatherIntroQuestProgress(bru);
|
||||||
|
|
||||||
|
const setup = await axios.post(
|
||||||
`${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`,
|
`${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`,
|
||||||
{ schemaVersion: 1 },
|
{ schemaVersion: 1 },
|
||||||
jsonHeaders,
|
{ ...jsonHeaders, validateStatus: () => true },
|
||||||
);
|
);
|
||||||
|
if (setup.status !== 200 || setup.data?.accepted !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`duplicate setup accept failed: ${setup.status} ${JSON.stringify(setup.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ meta {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-120: POST accept gather intro — not_started to active for dev-local-1.
|
NEO-120: POST accept gather intro — not_started to active for dev-local-1.
|
||||||
|
Pre-request clears gather intro progress so accept works after earlier collection tests or persisted Postgres.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const { resetGatherIntroQuestProgress } = require("./scripts/bruno-dev-fixture-helper");
|
||||||
|
await resetGatherIntroQuestProgress(bru);
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ meta {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-120: refine intro requires completed gather intro — deny prerequisite_incomplete without mutation.
|
NEO-120: refine intro requires completed gather intro — deny prerequisite_incomplete without mutation.
|
||||||
|
Pre-request clears gather and refine progress so gather intro is not completed.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const { resetQuestProgress } = require("./scripts/bruno-dev-fixture-helper");
|
||||||
|
await resetQuestProgress(bru, [
|
||||||
|
"prototype_quest_gather_intro",
|
||||||
|
"prototype_quest_refine_intro",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ meta {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-129: accept gather intro, complete via alpha gathers, GET quest-progress asserts completionRewardSummary; second GET unchanged.
|
NEO-129: accept gather intro, complete via alpha gathers, GET quest-progress asserts completionRewardSummary; second GET unchanged.
|
||||||
Tolerates craft spine (seq 5) partial progress and node_depleted on alpha when quest already completed.
|
Pre-request resets resource nodes, inventory, and gather quest progress so the test is self-contained within a full collection run.
|
||||||
}
|
}
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
|
const { resetGatherIntroSpine } = require("./scripts/bruno-dev-fixture-helper");
|
||||||
|
await resetGatherIntroSpine(bru);
|
||||||
|
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
|
@ -32,6 +35,17 @@ script:pre-request {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveNearAlpha() {
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/move`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
target: { x: 10, y: 0.9, z: -6 },
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function tryGatherAlpha() {
|
async function tryGatherAlpha() {
|
||||||
const interact = await axios.post(
|
const interact = await axios.post(
|
||||||
`${baseUrl}/game/players/${playerId}/interact`,
|
`${baseUrl}/game/players/${playerId}/interact`,
|
||||||
|
|
@ -50,28 +64,40 @@ script:pre-request {
|
||||||
if (interact.data?.reasonCode === "node_depleted") {
|
if (interact.data?.reasonCode === "node_depleted") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (interact.data?.reasonCode === "out_of_range") {
|
||||||
|
await moveNearAlpha();
|
||||||
|
const retry = await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/interact`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
interactableId: alphaNodeId,
|
||||||
|
},
|
||||||
|
{ headers, validateStatus: () => true },
|
||||||
|
);
|
||||||
|
if (retry.status !== 200) {
|
||||||
|
throw new Error(`gather interact retry HTTP ${retry.status}: ${JSON.stringify(retry.data)}`);
|
||||||
|
}
|
||||||
|
if (retry.data?.allowed === true || retry.data?.reasonCode === "node_depleted") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`gather interact denied after move: ${JSON.stringify(retry.data)}`);
|
||||||
|
}
|
||||||
throw new Error(`gather interact denied: ${JSON.stringify(interact.data)}`);
|
throw new Error(`gather interact denied: ${JSON.stringify(interact.data)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await axios.post(
|
const accept = await axios.post(
|
||||||
`${baseUrl}/game/players/${playerId}/quests/${gatherQuestId}/accept`,
|
`${baseUrl}/game/players/${playerId}/quests/${gatherQuestId}/accept`,
|
||||||
{ schemaVersion: 1 },
|
{ schemaVersion: 1 },
|
||||||
{ headers, validateStatus: () => true },
|
{ headers, validateStatus: () => true },
|
||||||
);
|
);
|
||||||
|
if (accept.status !== 200 || accept.data?.accepted !== true) {
|
||||||
|
throw new Error(`gather intro accept failed: ${accept.status} ${JSON.stringify(accept.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveNearAlpha();
|
||||||
|
|
||||||
let progress = await getQuestProgress();
|
let progress = await getQuestProgress();
|
||||||
let row = gatherRow(progress);
|
let row = gatherRow(progress);
|
||||||
if (!row || row.status === "not_started") {
|
|
||||||
await axios.post(
|
|
||||||
`${baseUrl}/game/players/${playerId}/move`,
|
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
target: { x: 10, y: 0.9, z: -6 },
|
|
||||||
},
|
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
progress = await getQuestProgress();
|
progress = await getQuestProgress();
|
||||||
row = gatherRow(progress);
|
row = gatherRow(progress);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ meta {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-119: fresh dev-local-1 row before any quest accept — all five catalog quests not_started.
|
NEO-119: fresh dev-local-1 row before any quest accept — all five catalog quests not_started.
|
||||||
|
Pre-request clears all prototype quest progress so the test is self-contained across collection runs.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const { resetAllPrototypeQuestProgress } = require("./scripts/bruno-dev-fixture-helper");
|
||||||
|
await resetAllPrototypeQuestProgress(bru);
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
const axios = require("axios");
|
||||||
|
const { resetPrototypeCombatTargets } = require("./combat-targets-reset-helper");
|
||||||
|
const { clearInventory } = require("./inventory-api-helper");
|
||||||
|
|
||||||
|
function resolveConfig(bru) {
|
||||||
|
return {
|
||||||
|
baseUrl: bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"),
|
||||||
|
playerId: bru.getEnvVar("playerId") || bru.getVar("playerId"),
|
||||||
|
jsonHeaders: { headers: { "Content-Type": "application/json" } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPrototypeResourceNodes(bru) {
|
||||||
|
const { baseUrl, jsonHeaders } = resolveConfig(bru);
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/game/__dev/resource-node-fixture`,
|
||||||
|
{ schemaVersion: 1 },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
if (response.status !== 200 || response.data?.applied !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`resource-node fixture reset failed: ${response.status} ${JSON.stringify(response.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetQuestProgress(bru, questIds) {
|
||||||
|
const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru);
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
|
||||||
|
{ schemaVersion: 1, resetQuestIds: questIds },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
if (response.status !== 200 || response.data?.applied !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`quest fixture reset failed: ${response.status} ${JSON.stringify(response.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_PROTOTYPE_QUEST_IDS = [
|
||||||
|
// Keep in sync with PrototypeE7M1QuestCatalogRules.ExpectedQuestIds (server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs).
|
||||||
|
"prototype_quest_combat_intro",
|
||||||
|
"prototype_quest_gather_intro",
|
||||||
|
"prototype_quest_grid_contract",
|
||||||
|
"prototype_quest_operator_chain",
|
||||||
|
"prototype_quest_refine_intro",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function assertAllPrototypeQuestsNotStarted(bru) {
|
||||||
|
const { baseUrl, playerId } = resolveConfig(bru);
|
||||||
|
const response = await axios.get(`${baseUrl}/game/players/${playerId}/quest-progress`, {
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`quest-progress verify GET failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notStarted = [];
|
||||||
|
for (const row of response.data.quests ?? []) {
|
||||||
|
if (row.status !== "not_started") {
|
||||||
|
notStarted.push(`${row.questId}:${row.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notStarted.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`quest fixture reset left non-not_started rows: ${notStarted.join(", ")}. Rebuild/restart the server with quest-fixture resetQuestIds support.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAllPrototypeQuestProgress(bru) {
|
||||||
|
await resetQuestProgress(bru, ALL_PROTOTYPE_QUEST_IDS);
|
||||||
|
await assertAllPrototypeQuestsNotStarted(bru);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetGatherIntroQuestProgress(bru) {
|
||||||
|
await resetQuestProgress(bru, ["prototype_quest_gather_intro"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetGatherIntroSpine(bru) {
|
||||||
|
await resetPrototypeResourceNodes(bru);
|
||||||
|
await clearInventory(bru);
|
||||||
|
await resetGatherIntroQuestProgress(bru);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resetPrototypeCombatTargets,
|
||||||
|
resetPrototypeResourceNodes,
|
||||||
|
resetQuestProgress,
|
||||||
|
resetAllPrototypeQuestProgress,
|
||||||
|
resetGatherIntroQuestProgress,
|
||||||
|
resetGatherIntroSpine,
|
||||||
|
clearInventory,
|
||||||
|
assertAllPrototypeQuestsNotStarted,
|
||||||
|
ALL_PROTOTYPE_QUEST_IDS,
|
||||||
|
};
|
||||||
|
|
@ -57,9 +57,42 @@ async function ensureItemQuantity(bru, itemId, minQuantity) {
|
||||||
return sumItemQuantity(after, itemId);
|
return sumItemQuantity(after, itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearInventory(bru) {
|
||||||
|
for (let pass = 0; pass < 32; pass += 1) {
|
||||||
|
const inventory = await getInventory(bru);
|
||||||
|
const totals = new Map();
|
||||||
|
for (const slot of [...inventory.bagSlots, ...inventory.equipmentSlots]) {
|
||||||
|
if (!slot.itemId || slot.quantity <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totals.set(slot.itemId, (totals.get(slot.itemId) ?? 0) + slot.quantity);
|
||||||
|
}
|
||||||
|
if (totals.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [itemId, quantity] of totals) {
|
||||||
|
const response = await postMutation(bru, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mutationKind: "remove",
|
||||||
|
itemId,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
if (response.status !== 200 || response.data?.applied !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`clear inventory remove ${itemId} failed: ${response.status} ${JSON.stringify(response.data)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("clearInventory: bag still not empty after removal passes");
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sumItemQuantity,
|
sumItemQuantity,
|
||||||
getInventory,
|
getInventory,
|
||||||
postMutation,
|
postMutation,
|
||||||
ensureItemQuantity,
|
ensureItemQuantity,
|
||||||
|
clearInventory,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Code review — Bruno self-reset dev fixtures
|
||||||
|
|
||||||
|
**Date:** 2026-06-15
|
||||||
|
**Scope:** Branch `chore/bruno-self-reset-fixtures` — commit `95cf5d1` vs `origin/main`
|
||||||
|
**Base:** `origin/main`
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Approve with nits**
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This chore branch makes the Bruno server collection **order-independent** by adding dev-only fixture reset hooks and wiring them into quest, gather, and combat `.bru` pre-request scripts. Server-side changes extend existing fixture patterns: **`resetQuestIds`** on the quest fixture API, a new **`POST /game/__dev/resource-node-fixture`**, **`TrySetRemainingGathers`** on the resource-node instance store, and **`ClearPlayer`** on the ability cooldown store (called from the combat-target fixture). Bruno helpers centralize reset logic in `scripts/bruno-dev-fixture-helper.js`; quest and gather tests self-reset before asserting. Tests are solid (803 pass locally), CI enables the new resource-node fixture flag, and dev routes remain gated behind Development/Testing/config flags. Residual risk is low — dev-only surface, no player-facing client change. **Follow-up:** README, Postgres `TryClearProgress` integration test, and Bruno nit fixes applied post-review.
|
||||||
|
|
||||||
|
## Documentation checked
|
||||||
|
|
||||||
|
| Path | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `docs/plans/NEO-116-implementation-plan.md` | **Partially matches** — open question “Dev fixture quest reset” was **deferred**; this branch implements that deferred hook (`resetQuestIds`). No dedicated plan for this chore; acceptable for infra work but README should record the landed behavior. |
|
||||||
|
| `docs/plans/NEO-137-implementation-plan.md` | **N/A (extended)** — quest fixture originally documented **`completedQuestIds` only**; this branch adds **`resetQuestIds`** without updating NEO-137 plan (fine for chore; README should be source of truth). |
|
||||||
|
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **N/A change** — reset is dev fixture bypass, not quest state machine semantics. |
|
||||||
|
| `docs/decomposition/modules/E3_M1_GatheringAndSalvage.md` (gathering) | **Partially matches** — depletion store behavior unchanged for gameplay; fixture reset is dev-only upsert to catalog **`maxGathers`**. No module status update required. |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **N/A change** — no tracking-table row for Bruno fixture infra. |
|
||||||
|
| `server/README.md` | **Matches** — quest fixture documents **`resetQuestIds`** + Bruno helper pointer; combat-target fixture documents cooldown clear; resource-node fixture documented. |
|
||||||
|
|
||||||
|
## Blocking issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Suggestions
|
||||||
|
|
||||||
|
1. ~~**Update `server/README.md`** for the three fixture surfaces changed in this branch:~~
|
||||||
|
- ~~Quest fixture: document optional **`resetQuestIds`** (clears progress rows, idempotent when absent; can combine with **`completedQuestIds`**).~~
|
||||||
|
- ~~Combat-target fixture: note **`IPlayerAbilityCooldownStore.ClearPlayer`** for the dev player.~~
|
||||||
|
- ~~New **`POST /game/__dev/resource-node-fixture`** section (`Game:EnableResourceNodeFixtureApi`, restores all catalog nodes to **`maxGathers`**, Bruno helper `resetPrototypeResourceNodes`).~~
|
||||||
|
- ~~Brief pointer to **`scripts/bruno-dev-fixture-helper.js`** as the shared Bruno reset entry point for quest/gather/inventory flows.~~ **Done.**
|
||||||
|
|
||||||
|
2. ~~**Add a Postgres integration test for `TryClearProgress`** (optional but valuable) — current reset coverage is in-memory + Bruno CI against Postgres; a focused persistence test would mirror other quest-store integration tests and catch store divergence early.~~ **Done.** (`PlayerQuestProgressPersistenceIntegrationTests.TryClearProgress_ShouldRemoveRowAndPersistAcrossNewFactory`)
|
||||||
|
|
||||||
|
3. ~~**Keep prototype quest id lists in sync** — `ALL_PROTOTYPE_QUEST_IDS` in `bruno-dev-fixture-helper.js` duplicates `PrototypeE7M1QuestCatalogRules` in C#. Add a one-line comment in the JS file pointing at the C# source of truth, or extract a shared JSON manifest if the roster grows again.~~ **Done.** (comment points at `PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`)
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
- ~~Nit: New ability-cast pre-requests use `require("./scripts/combat-targets-reset-helper")` without the `.js` suffix; most sibling `.bru` files use `.js`. Harmless in Bruno developer sandbox but inconsistent.~~ **Done.**
|
||||||
|
- Nit: `clearInventory` caps at 32 removal passes — sufficient for prototype bag sizes; error message is clear if exceeded.
|
||||||
|
- Nit: `assertAllPrototypeQuestsNotStarted` verifies every row in the GET response, not just the five reset ids — correct for the default-state test but will fail loudly if the catalog gains quests not listed in `ALL_PROTOTYPE_QUEST_IDS`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test NeonSprawl.sln --configuration Release
|
||||||
|
```
|
||||||
|
|
||||||
|
With Postgres (matches CI Bruno step):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — enable all dev fixtures
|
||||||
|
ConnectionStrings__NeonSprawl='Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev' \
|
||||||
|
Game__EnableCombatTargetFixtureApi=true \
|
||||||
|
Game__EnableQuestFixtureApi=true \
|
||||||
|
Game__EnableResourceNodeFixtureApi=true \
|
||||||
|
dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj --urls http://127.0.0.1:5253
|
||||||
|
|
||||||
|
# Terminal 2
|
||||||
|
cd bruno/neon-sprawl-server
|
||||||
|
npx --yes @usebruno/cli@3.3.0 run --env Local --sandbox=developer --bail
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual spot-check: run the quest-progress collection **twice** back-to-back without restarting the server — `Get quest progress default.bru` and `Accept duplicate.bru` should pass both times.
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Combat;
|
using NeonSprawl.Server.Game.Combat;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
|
|
@ -79,6 +80,30 @@ public sealed class CombatTargetFixtureApiTests
|
||||||
Assert.False(eventStore.TryGet("dev-local-1", "prototype_combat_pocket", out _));
|
Assert.False(eventStore.TryGet("dev-local-1", "prototype_combat_pocket", out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostCombatTargetFixture_ShouldClearAbilityCooldowns_WhenEnabled()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var cooldownStore = factory.Services.GetRequiredService<IPlayerAbilityCooldownStore>();
|
||||||
|
var now = factory.FakeClock!.GetUtcNow();
|
||||||
|
cooldownStore.StartCooldown("dev-local-1", slotIndex: 0, now, TimeSpan.FromSeconds(30));
|
||||||
|
Assert.True(cooldownStore.IsOnCooldown("dev-local-1", slotIndex: 0, now));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
new CombatTargetFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = CombatTargetFixtureRequest.CurrentSchemaVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.False(cooldownStore.IsOnCooldown("dev-local-1", slotIndex: 0, now));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostCombatTargetFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
|
public async Task PostCombatTargetFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,22 @@ public sealed class InMemoryResourceNodeInstanceStoreTests
|
||||||
Assert.Equal(7, snapshot.RemainingGathers);
|
Assert.Equal(7, snapshot.RemainingGathers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrySetRemainingGathers_ShouldOverwriteExistingCapacity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = new InMemoryResourceNodeInstanceStore();
|
||||||
|
store.TryEnsureInitialized(NodeId, initialRemainingGathers: 2);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var applied = store.TrySetRemainingGathers(NodeId, remainingGathers: 10);
|
||||||
|
store.TryGetRemainingGathers(NodeId, out var snapshot);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(applied);
|
||||||
|
Assert.Equal(10, snapshot.RemainingGathers);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryGetRemainingGathers_ForUnknownId_ShouldReturnFalse()
|
public void TryGetRemainingGathers_ForUnknownId_ShouldReturnFalse()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Gathering;
|
||||||
|
|
||||||
|
public sealed class ResourceNodeFixtureApiTests
|
||||||
|
{
|
||||||
|
private const string FixturePath = "/game/__dev/resource-node-fixture";
|
||||||
|
private const string AlphaId = "prototype_resource_node_alpha";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostResourceNodeFixture_ShouldRestoreMaxGathers_WhenNodeDepleted()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var store = factory.Services.GetRequiredService<IResourceNodeInstanceStore>();
|
||||||
|
var registry = factory.Services.GetRequiredService<IResourceNodeDefinitionRegistry>();
|
||||||
|
registry.TryGetDefinition(AlphaId, out var definition);
|
||||||
|
store.TryEnsureInitialized(AlphaId, initialRemainingGathers: 0);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
new ResourceNodeFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = ResourceNodeFixtureRequest.CurrentSchemaVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ResourceNodeFixtureResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.True(body!.Applied);
|
||||||
|
store.TryGetRemainingGathers(AlphaId, out var snapshot);
|
||||||
|
Assert.Equal(definition!.MaxGathers, snapshot.RemainingGathers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostResourceNodeFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.WithWebHostBuilder(b =>
|
||||||
|
{
|
||||||
|
b.UseEnvironment("Production");
|
||||||
|
b.UseSetting("Game:EnableResourceNodeFixtureApi", "false");
|
||||||
|
}).CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
new ResourceNodeFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = ResourceNodeFixtureRequest.CurrentSchemaVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -90,6 +90,35 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg
|
||||||
Assert.Equal(CompletedAt, readBack.CompletedAt);
|
Assert.Equal(CompletedAt, readBack.CompletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RequirePostgresFact]
|
||||||
|
public async Task TryClearProgress_ShouldRemoveRowAndPersistAcrossNewFactory()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await ResetQuestProgressTableAsync();
|
||||||
|
var questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
using (var firstScope = Factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var store = firstScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||||
|
Assert.True(store.TryActivate(PlayerId, questId, out _));
|
||||||
|
Assert.True(store.TryMarkComplete(PlayerId, questId, CompletedAt, out _));
|
||||||
|
Assert.True(store.TryClearProgress(PlayerId, questId));
|
||||||
|
Assert.False(store.TryGetProgress(PlayerId, questId, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
var readBackExists = false;
|
||||||
|
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||||
|
{
|
||||||
|
using var secondScope = secondFactory.Services.CreateScope();
|
||||||
|
var store = secondScope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
|
||||||
|
readBackExists = store.TryGetProgress(PlayerId, questId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(readBackExists);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ResetQuestProgressTableAsync()
|
private async Task ResetQuestProgressTableAsync()
|
||||||
{
|
{
|
||||||
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||||
|
|
|
||||||
|
|
@ -127,4 +127,65 @@ public sealed class QuestFixtureApiTests
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostQuestFixture_ShouldClearProgress_WhenResetQuestIdsProvided()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
|
||||||
|
var markCompleted = await client.PostAsJsonAsync(FixturePath, ValidFixture(ChainQuestId));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, markCompleted.StatusCode);
|
||||||
|
Assert.True(progressStore.TryGetProgress(DevPlayer, ChainQuestId, out _));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var reset = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
new QuestFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
|
||||||
|
ResetQuestIds = [ChainQuestId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
|
||||||
|
Assert.False(progressStore.TryGetProgress(DevPlayer, ChainQuestId, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostQuestFixture_ResetAllPrototypeQuests_ShouldReturnNotStartedOnGetQuestProgress()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var markCompleted = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
ValidFixture(ChainQuestId, PrototypeE7M1QuestCatalogRules.GatherIntroQuestId));
|
||||||
|
Assert.Equal(HttpStatusCode.OK, markCompleted.StatusCode);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var reset = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
new QuestFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
|
||||||
|
ResetQuestIds =
|
||||||
|
[
|
||||||
|
PrototypeE7M1QuestCatalogRules.CombatIntroQuestId,
|
||||||
|
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
|
||||||
|
PrototypeE7M1QuestCatalogRules.GridContractQuestId,
|
||||||
|
PrototypeE7M1QuestCatalogRules.ChainQuestId,
|
||||||
|
PrototypeE7M1QuestCatalogRules.RefineIntroQuestId,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
var progress = await client.GetAsync($"/game/players/{DevPlayer}/quest-progress");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, progress.StatusCode);
|
||||||
|
var body = await progress.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
builder.UseSetting("Game:EnableQuestFixtureApi", "true");
|
||||||
|
builder.UseSetting("Game:EnableResourceNodeFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,7 @@ public interface IPlayerAbilityCooldownStore
|
||||||
|
|
||||||
/// <summary>Gets active cooldown end when <paramref name="now"/> is strictly before that end; removes expired entries (same hygiene as <see cref="IsOnCooldown"/>).</summary>
|
/// <summary>Gets active cooldown end when <paramref name="now"/> is strictly before that end; removes expired entries (same hygiene as <see cref="IsOnCooldown"/>).</summary>
|
||||||
bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc);
|
bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc);
|
||||||
|
|
||||||
|
/// <summary>Clears all slot cooldowns for <paramref name="playerId"/> (dev Bruno fixture).</summary>
|
||||||
|
void ClearPlayer(string playerId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,4 +56,7 @@ public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownS
|
||||||
{
|
{
|
||||||
return TryGetActiveCooldownEnd(ends, playerId, slotIndex, now, out endUtc);
|
return TryGetActiveCooldownEnd(ends, playerId, slotIndex, now, out endUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ClearPlayer(string playerId) => _ = ends.TryRemove(playerId, out _);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
using NeonSprawl.Server.Game.Encounters;
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
@ -22,6 +23,7 @@ public static class CombatTargetFixtureApi
|
||||||
IEncounterProgressStore encounterProgressStore,
|
IEncounterProgressStore encounterProgressStore,
|
||||||
IEncounterCompletionStore encounterCompletionStore,
|
IEncounterCompletionStore encounterCompletionStore,
|
||||||
IEncounterCompleteEventStore encounterCompleteEventStore,
|
IEncounterCompleteEventStore encounterCompleteEventStore,
|
||||||
|
IPlayerAbilityCooldownStore cooldownStore,
|
||||||
IOptions<GamePositionOptions> gameOptions) =>
|
IOptions<GamePositionOptions> gameOptions) =>
|
||||||
{
|
{
|
||||||
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
|
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
|
||||||
|
|
@ -53,6 +55,8 @@ public static class CombatTargetFixtureApi
|
||||||
encounterCompletionStore,
|
encounterCompletionStore,
|
||||||
encounterCompleteEventStore);
|
encounterCompleteEventStore);
|
||||||
|
|
||||||
|
cooldownStore.ClearPlayer(devPlayerId);
|
||||||
|
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
new CombatTargetFixtureResponse
|
new CombatTargetFixtureResponse
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,7 @@ public interface IResourceNodeInstanceStore
|
||||||
string interactableId,
|
string interactableId,
|
||||||
out int previousRemaining,
|
out int previousRemaining,
|
||||||
out int remainingGathers);
|
out int remainingGathers);
|
||||||
|
|
||||||
|
/// <summary>Sets remaining gathers (upsert). Used by dev Bruno fixture reset.</summary>
|
||||||
|
bool TrySetRemainingGathers(string interactableId, int remainingGathers);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,4 +85,20 @@ public sealed class InMemoryResourceNodeInstanceStore : IResourceNodeInstanceSto
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TrySetRemainingGathers(string interactableId, int remainingGathers)
|
||||||
|
{
|
||||||
|
var key = ResourceNodeInstanceIds.Normalize(interactableId);
|
||||||
|
if (key.Length == 0 || remainingGathers < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
remainingById[key] = remainingGathers;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,4 +97,30 @@ public sealed class PostgresResourceNodeInstanceStore(Npgsql.NpgsqlDataSource da
|
||||||
remainingGathers = reader.GetInt32(1);
|
remainingGathers = reader.GetInt32(1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TrySetRemainingGathers(string interactableId, int remainingGathers)
|
||||||
|
{
|
||||||
|
var key = ResourceNodeInstanceIds.Normalize(interactableId);
|
||||||
|
if (key.Length == 0 || remainingGathers < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresResourceNodeInstanceBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO resource_node_instance (interactable_id, remaining_gathers, updated_at)
|
||||||
|
VALUES (@id, @remaining, now())
|
||||||
|
ON CONFLICT (interactable_id) DO UPDATE
|
||||||
|
SET remaining_gathers = EXCLUDED.remaining_gathers,
|
||||||
|
updated_at = now();
|
||||||
|
""",
|
||||||
|
conn);
|
||||||
|
cmd.Parameters.AddWithValue("id", key);
|
||||||
|
cmd.Parameters.AddWithValue("remaining", remainingGathers);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>Dev-only route to reset prototype resource-node gathers for Bruno and manual QA.</summary>
|
||||||
|
public static class ResourceNodeFixtureApi
|
||||||
|
{
|
||||||
|
public static WebApplication MapResourceNodeFixtureApi(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost(
|
||||||
|
"/game/__dev/resource-node-fixture",
|
||||||
|
(ResourceNodeFixtureRequest? body, IResourceNodeDefinitionRegistry nodeRegistry,
|
||||||
|
IResourceNodeInstanceStore instanceStore) =>
|
||||||
|
{
|
||||||
|
if (body is null || body.SchemaVersion != ResourceNodeFixtureRequest.CurrentSchemaVersion)
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ResourceNodeFixtureOperations.TryApply(nodeRegistry, instanceStore))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(new ResourceNodeFixtureResponse { Applied = true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>JSON body for <c>POST /game/__dev/resource-node-fixture</c> (Bruno/manual QA).</summary>
|
||||||
|
public sealed class ResourceNodeFixtureRequest
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
public int SchemaVersion { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST response for resource-node fixture apply.</summary>
|
||||||
|
public sealed class ResourceNodeFixtureResponse
|
||||||
|
{
|
||||||
|
public bool Applied { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>Resets prototype resource-node instance capacity for Bruno and manual QA.</summary>
|
||||||
|
public static class ResourceNodeFixtureOperations
|
||||||
|
{
|
||||||
|
/// <summary>Sets each catalog node's remaining gathers to its <c>maxGathers</c>.</summary>
|
||||||
|
public static bool TryApply(
|
||||||
|
IResourceNodeDefinitionRegistry nodeRegistry,
|
||||||
|
IResourceNodeInstanceStore instanceStore)
|
||||||
|
{
|
||||||
|
foreach (var definition in nodeRegistry.GetDefinitionsInIdOrder())
|
||||||
|
{
|
||||||
|
if (!instanceStore.TrySetRemainingGathers(definition.NodeDefId, definition.MaxGathers))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,9 @@ public sealed class GamePositionOptions
|
||||||
/// <summary>When true, maps <c>POST …/__dev/quest-fixture</c> for Bruno/manual QA quest progress setup (also enabled in Development).</summary>
|
/// <summary>When true, maps <c>POST …/__dev/quest-fixture</c> for Bruno/manual QA quest progress setup (also enabled in Development).</summary>
|
||||||
public bool EnableQuestFixtureApi { get; set; }
|
public bool EnableQuestFixtureApi { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true, maps <c>POST /game/__dev/resource-node-fixture</c> to reset prototype node gathers (also enabled in Development/Testing).</summary>
|
||||||
|
public bool EnableResourceNodeFixtureApi { get; set; }
|
||||||
|
|
||||||
/// <summary>World position for the dev player at process start.</summary>
|
/// <summary>World position for the dev player at process start.</summary>
|
||||||
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,7 @@ public interface IPlayerQuestStateStore
|
||||||
|
|
||||||
/// <summary>First completion returns <c>true</c>; replays return <c>false</c> without changing <see cref="QuestStepState.CompletedAt"/>.</summary>
|
/// <summary>First completion returns <c>true</c>; replays return <c>false</c> without changing <see cref="QuestStepState.CompletedAt"/>.</summary>
|
||||||
bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot);
|
bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot);
|
||||||
|
|
||||||
|
/// <summary>Removes progress row when present; idempotent when absent (dev Bruno fixture).</summary>
|
||||||
|
bool TryClearProgress(string playerId, string questId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,4 +232,22 @@ public sealed class InMemoryPlayerQuestStateStore(IOptions<GamePositionOptions>
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryClearProgress(string playerId, string questId)
|
||||||
|
{
|
||||||
|
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||||
|
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||||
|
var key = QuestProgressIds.MakeProgressKey(player, quest);
|
||||||
|
if (key.Length == 0 || !knownPlayers.Contains(player))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
_ = byKey.TryRemove(key, out _);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,30 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo
|
||||||
}, out snapshot);
|
}, out snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryClearProgress(string playerId, string questId)
|
||||||
|
{
|
||||||
|
var player = QuestProgressIds.NormalizePlayerId(playerId);
|
||||||
|
var quest = QuestProgressIds.NormalizeQuestId(questId);
|
||||||
|
if (player.Length == 0 || quest.Length == 0 || !CanWritePlayer(player))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
DELETE FROM player_quest_progress
|
||||||
|
WHERE player_id = @pid AND quest_id = @qid;
|
||||||
|
""",
|
||||||
|
conn);
|
||||||
|
cmd.Parameters.AddWithValue("pid", player);
|
||||||
|
cmd.Parameters.AddWithValue("qid", quest);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private bool TryMutateRow(
|
private bool TryMutateRow(
|
||||||
string player,
|
string player,
|
||||||
string quest,
|
string quest,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Quests;
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
/// <summary>POST body for <c>POST /game/players/{{id}}/__dev/quest-fixture</c> (NEO-137 Bruno/manual QA).</summary>
|
/// <summary>POST body for <c>POST /game/players/{{id}}/__dev/quest-fixture</c> (NEO-137 Bruno/manual QA).</summary>
|
||||||
|
|
@ -5,10 +7,16 @@ public sealed class QuestFixtureRequest
|
||||||
{
|
{
|
||||||
public const int CurrentSchemaVersion = 1;
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
public int SchemaVersion { get; init; }
|
public int SchemaVersion { get; init; }
|
||||||
|
|
||||||
/// <summary>Quest ids to mark completed via store activate+complete (no reward delivery).</summary>
|
/// <summary>Quest ids to mark completed via store activate+complete (no reward delivery).</summary>
|
||||||
|
[JsonPropertyName("completedQuestIds")]
|
||||||
public IReadOnlyList<string> CompletedQuestIds { get; init; } = [];
|
public IReadOnlyList<string> CompletedQuestIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Quest ids to clear from player progress (dev Bruno reset).</summary>
|
||||||
|
[JsonPropertyName("resetQuestIds")]
|
||||||
|
public IReadOnlyList<string> ResetQuestIds { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST response for quest fixture apply.</summary>
|
/// <summary>POST response for quest fixture apply.</summary>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public static class QuestFixtureOperations
|
||||||
IPlayerQuestStateStore progressStore,
|
IPlayerQuestStateStore progressStore,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
if (body.CompletedQuestIds.Count == 0)
|
if (body.CompletedQuestIds.Count == 0 && body.ResetQuestIds.Count == 0)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +24,19 @@ public static class QuestFixtureOperations
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var questId in body.ResetQuestIds)
|
||||||
|
{
|
||||||
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!progressStore.TryClearProgress(playerId, normalizedQuestId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var questId in body.CompletedQuestIds)
|
foreach (var questId in body.CompletedQuestIds)
|
||||||
{
|
{
|
||||||
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,13 @@ if (app.Environment.IsDevelopment() ||
|
||||||
app.MapQuestFixtureApi();
|
app.MapQuestFixtureApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment() ||
|
||||||
|
app.Environment.IsEnvironment("Testing") ||
|
||||||
|
app.Configuration.GetValue<bool>("Game:EnableResourceNodeFixtureApi"))
|
||||||
|
{
|
||||||
|
app.MapResourceNodeFixtureApi();
|
||||||
|
}
|
||||||
|
|
||||||
app.MapTargetingApi();
|
app.MapTargetingApi();
|
||||||
app.MapHotbarLoadoutApi();
|
app.MapHotbarLoadoutApi();
|
||||||
app.MapCooldownSnapshotApi();
|
app.MapCooldownSnapshotApi();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"Game": {
|
"Game": {
|
||||||
"EnableMasteryFixtureApi": true,
|
"EnableMasteryFixtureApi": true,
|
||||||
"EnableCombatTargetFixtureApi": true,
|
"EnableCombatTargetFixtureApi": true,
|
||||||
"EnableQuestFixtureApi": true
|
"EnableQuestFixtureApi": true,
|
||||||
|
"EnableResourceNodeFixtureApi": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,13 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
|
||||||
|
|
||||||
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
|
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
|
||||||
|
|
||||||
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** and **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed. **400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` pre-request.
|
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional:
|
||||||
|
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed.
|
||||||
|
- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent). Can be combined with **`completedQuestIds`** in one request (resets run first).
|
||||||
|
|
||||||
|
**400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` ( **`completedQuestIds`** ); order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
|
||||||
|
|
||||||
|
**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**.
|
||||||
|
|
||||||
## Resource-node catalog (`content/resource-nodes`, NEO-58)
|
## Resource-node catalog (`content/resource-nodes`, NEO-58)
|
||||||
|
|
||||||
|
|
@ -521,7 +527,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m
|
||||||
curl -sS -i "http://localhost:5253/game/world/combat-targets"
|
curl -sS -i "http://localhost:5253/game/world/combat-targets"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), restores dev player combat HP to full (**NEO-95**), and clears in-memory encounter progress/completion/event rows for the dev player across catalog encounters (**NEO-108** Bruno). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js` (request scripts use `./scripts/…` relative to the collection).
|
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), restores dev player combat HP to full (**NEO-95**), clears **`IPlayerAbilityCooldownStore`** slot cooldowns for the configured dev player, and clears in-memory encounter progress/completion/event rows for the dev player across catalog encounters (**NEO-108** Bruno). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js` (request scripts use `./scripts/…` relative to the collection).
|
||||||
|
|
||||||
## Threat / aggro state (NEO-92)
|
## Threat / aggro state (NEO-92)
|
||||||
|
|
||||||
|
|
@ -690,6 +696,8 @@ World-wide **remaining gathers** per **`interactableId`** live in **`Game/Gather
|
||||||
|
|
||||||
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as inventory — **`resource_node_instance`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V006__resource_node_instance.sql`](../db/migrations/V006__resource_node_instance.sql)), otherwise in-memory fallback (empty at startup). Interact deny wiring: [NEO-63](#gather-via-interact-neo-63); plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md).
|
**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as inventory — **`resource_node_instance`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V006__resource_node_instance.sql`](../db/migrations/V006__resource_node_instance.sql)), otherwise in-memory fallback (empty at startup). Interact deny wiring: [NEO-63](#gather-via-interact-neo-63); plan: [NEO-61 implementation plan](../../docs/plans/NEO-61-implementation-plan.md).
|
||||||
|
|
||||||
|
**Dev resource-node fixture (Bruno/manual QA):** When `Game:EnableResourceNodeFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/resource-node-fixture`** with `schemaVersion` **1** upserts every catalog node to **`maxGathers`** via **`TrySetRemainingGathers`**. **404** when disabled. Bruno: `scripts/bruno-dev-fixture-helper.js` (`resetPrototypeResourceNodes`).
|
||||||
|
|
||||||
## Gather engine (NEO-62)
|
## Gather engine (NEO-62)
|
||||||
|
|
||||||
**`GatherOperations.TryGather`** in **`Game/Gathering/`** resolves server-internal **`GatherResult`**: capacity pre-check (may **initialize** a depletion row without decrementing), inventory grant via **`PlayerInventoryOperations`**, **`salvage`** skill XP via **`SkillProgressionGrantOperations`** + **`GatherSkillXpConstants`**, then depletion commit via **`ResourceNodeInstanceOperations`** (decrement **last**, only when inventory + XP succeed). On XP failure after inventory add, the engine **rolls back** the item grant before denying.
|
**`GatherOperations.TryGather`** in **`Game/Gathering/`** resolves server-internal **`GatherResult`**: capacity pre-check (may **initialize** a depletion row without decrementing), inventory grant via **`PlayerInventoryOperations`**, **`salvage`** skill XP via **`SkillProgressionGrantOperations`** + **`GatherSkillXpConstants`**, then depletion commit via **`ResourceNodeInstanceOperations`** (decrement **last**, only when inventory + XP succeed). On XP failure after inventory add, the engine **rolls back** the item grant before denying.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue