From 95cf5d1f8301285c7115455d92945d5149c595ef Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 21:50:22 -0400 Subject: [PATCH 1/2] chore: add Bruno self-reset dev fixtures for order-independent collection runs. Extend quest and resource-node fixture APIs so Bruno pre-requests can reset shared dev-local-1 state (cooldowns, nodes, inventory, quest progress) instead of relying on a fresh Postgres baseline. --- .github/workflows/dotnet.yml | 1 + .../ability-cast/Post cast happy.bru | 3 + .../Post cast lock pulse defeat spine.bru | 3 + .../quest-progress/Accept duplicate.bru | 13 ++- .../quest-progress/Accept gather intro.bru | 6 ++ .../Accept refine prerequisite deny.bru | 9 ++ ...t progress after gather intro complete.bru | 52 +++++++--- .../Get quest progress default.bru | 6 ++ .../scripts/bruno-dev-fixture-helper.js | 96 +++++++++++++++++++ .../scripts/inventory-api-helper.js | 33 +++++++ .../Combat/CombatTargetFixtureApiTests.cs | 25 +++++ .../InMemoryResourceNodeInstanceStoreTests.cs | 16 ++++ .../Gathering/ResourceNodeFixtureApiTests.cs | 66 +++++++++++++ .../Game/Quests/QuestFixtureApiTests.cs | 61 ++++++++++++ .../InMemoryWebApplicationFactory.cs | 1 + .../IPlayerAbilityCooldownStore.cs | 3 + .../InMemoryPlayerAbilityCooldownStore.cs | 3 + .../Game/Combat/CombatTargetFixtureApi.cs | 4 + .../Gathering/IResourceNodeInstanceStore.cs | 3 + .../InMemoryResourceNodeInstanceStore.cs | 16 ++++ .../PostgresResourceNodeInstanceStore.cs | 26 +++++ .../Game/Gathering/ResourceNodeFixtureApi.cs | 28 ++++++ .../Game/Gathering/ResourceNodeFixtureDtos.cs | 15 +++ .../ResourceNodeFixtureOperations.cs | 21 ++++ .../Game/PositionState/GamePositionOptions.cs | 3 + .../Game/Quests/IPlayerQuestStateStore.cs | 3 + .../Quests/InMemoryPlayerQuestStateStore.cs | 18 ++++ .../Quests/PostgresPlayerQuestStateStore.cs | 24 +++++ .../Game/Quests/QuestFixtureDtos.cs | 8 ++ .../Game/Quests/QuestFixtureOperations.cs | 15 ++- server/NeonSprawl.Server/Program.cs | 7 ++ .../appsettings.Development.json | 3 +- 32 files changed, 574 insertions(+), 17 deletions(-) create mode 100644 bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js create mode 100644 server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeFixtureApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureApi.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureOperations.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index aea996d..c5af815 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -94,6 +94,7 @@ jobs: Host=localhost;Port=5432;Database=neon_sprawl;Username=neon_sprawl;Password=neon_sprawl_dev Game__EnableCombatTargetFixtureApi: "true" Game__EnableQuestFixtureApi: "true" + Game__EnableResourceNodeFixtureApi: "true" run: | set -euo pipefail dotnet run --project server/NeonSprawl.Server/NeonSprawl.Server.csproj \ diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index 932d4ea..15b8fa2 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -5,6 +5,9 @@ meta { } script:pre-request { + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper"); + await resetPrototypeCombatTargets(bru); + const axios = require("axios"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru index 44fe54b..6bde75c 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -9,6 +9,9 @@ docs { } script:pre-request { + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper"); + await resetPrototypeCombatTargets(bru); + const axios = require("axios"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); diff --git a/bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru b/bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru index d6f0e4b..ec7ae7c 100644 --- a/bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru +++ b/bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru @@ -11,14 +11,23 @@ docs { script:pre-request { const axios = require("axios"); + const { resetGatherIntroQuestProgress } = require("./scripts/bruno-dev-fixture-helper"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); 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`, { 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 { diff --git a/bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru b/bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru index a35a8b6..d2302ff 100644 --- a/bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru +++ b/bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru @@ -6,6 +6,12 @@ meta { docs { 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 { diff --git a/bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru b/bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru index 32cd36e..7373896 100644 --- a/bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru +++ b/bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru @@ -6,6 +6,15 @@ meta { docs { 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 { diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru index ac37ad7..c7aff9b 100644 --- a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru @@ -6,10 +6,13 @@ meta { docs { 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 { + const { resetGatherIntroSpine } = require("./scripts/bruno-dev-fixture-helper"); + await resetGatherIntroSpine(bru); + const axios = require("axios"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); @@ -32,6 +35,17 @@ script:pre-request { 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() { const interact = await axios.post( `${baseUrl}/game/players/${playerId}/interact`, @@ -50,28 +64,40 @@ script:pre-request { if (interact.data?.reasonCode === "node_depleted") { 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)}`); } - await axios.post( + const accept = await axios.post( `${baseUrl}/game/players/${playerId}/quests/${gatherQuestId}/accept`, { schemaVersion: 1 }, { 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 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) { progress = await getQuestProgress(); row = gatherRow(progress); diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru index 8c3a811..7465d33 100644 --- a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru @@ -6,6 +6,12 @@ meta { docs { 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 { diff --git a/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js new file mode 100644 index 0000000..d3a9973 --- /dev/null +++ b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js @@ -0,0 +1,96 @@ +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 = [ + "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, +}; diff --git a/bruno/neon-sprawl-server/scripts/inventory-api-helper.js b/bruno/neon-sprawl-server/scripts/inventory-api-helper.js index ecf4dec..5edf922 100644 --- a/bruno/neon-sprawl-server/scripts/inventory-api-helper.js +++ b/bruno/neon-sprawl-server/scripts/inventory-api-helper.js @@ -57,9 +57,42 @@ async function ensureItemQuantity(bru, itemId, minQuantity) { 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 = { sumItemQuantity, getInventory, postMutation, ensureItemQuantity, + clearInventory, }; diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index 0cf8c35..b80863a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Npc; @@ -79,6 +80,30 @@ public sealed class CombatTargetFixtureApiTests 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(); + 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] public async Task PostCombatTargetFixture_ShouldReturnNotFound_WhenRouteNotRegistered() { diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs index 76a20d2..08d1f2f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/InMemoryResourceNodeInstanceStoreTests.cs @@ -61,6 +61,22 @@ public sealed class InMemoryResourceNodeInstanceStoreTests 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] public void TryGetRemainingGathers_ForUnknownId_ShouldReturnFalse() { diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeFixtureApiTests.cs new file mode 100644 index 0000000..7456a11 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeFixtureApiTests.cs @@ -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(); + var registry = factory.Services.GetRequiredService(); + 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(); + 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs index e18efc8..89a2f59 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs @@ -127,4 +127,65 @@ public sealed class QuestFixtureApiTests // Assert 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(); + 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(); + Assert.NotNull(body); + Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status)); + } } diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 573332b..4f9a577 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -79,6 +79,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs b/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs index 408b0cc..d65b7d3 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/IPlayerAbilityCooldownStore.cs @@ -11,4 +11,7 @@ public interface IPlayerAbilityCooldownStore /// Gets active cooldown end when is strictly before that end; removes expired entries (same hygiene as ). bool TryGetCooldownEndUtc(string playerId, int slotIndex, DateTimeOffset now, out DateTimeOffset endUtc); + + /// Clears all slot cooldowns for (dev Bruno fixture). + void ClearPlayer(string playerId); } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs b/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs index 8d1ad06..b36c4b6 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/InMemoryPlayerAbilityCooldownStore.cs @@ -56,4 +56,7 @@ public sealed class InMemoryPlayerAbilityCooldownStore : IPlayerAbilityCooldownS { return TryGetActiveCooldownEnd(ends, playerId, slotIndex, now, out endUtc); } + + /// + public void ClearPlayer(string playerId) => _ = ends.TryRemove(playerId, out _); } diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 246e6c8..f2d140e 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; @@ -22,6 +23,7 @@ public static class CombatTargetFixtureApi IEncounterProgressStore encounterProgressStore, IEncounterCompletionStore encounterCompletionStore, IEncounterCompleteEventStore encounterCompleteEventStore, + IPlayerAbilityCooldownStore cooldownStore, IOptions gameOptions) => { if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) @@ -53,6 +55,8 @@ public static class CombatTargetFixtureApi encounterCompletionStore, encounterCompleteEventStore); + cooldownStore.ClearPlayer(devPlayerId); + return Results.Json( new CombatTargetFixtureResponse { diff --git a/server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs b/server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs index e96a6d0..6615bc6 100644 --- a/server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Gathering/IResourceNodeInstanceStore.cs @@ -19,4 +19,7 @@ public interface IResourceNodeInstanceStore string interactableId, out int previousRemaining, out int remainingGathers); + + /// Sets remaining gathers (upsert). Used by dev Bruno fixture reset. + bool TrySetRemainingGathers(string interactableId, int remainingGathers); } diff --git a/server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs b/server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs index c061272..d96a5d0 100644 --- a/server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Gathering/InMemoryResourceNodeInstanceStore.cs @@ -85,4 +85,20 @@ public sealed class InMemoryResourceNodeInstanceStore : IResourceNodeInstanceSto return true; } } + + /// + 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; + } + } } diff --git a/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs b/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs index a00f8aa..f34cd87 100644 --- a/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs +++ b/server/NeonSprawl.Server/Game/Gathering/PostgresResourceNodeInstanceStore.cs @@ -97,4 +97,30 @@ public sealed class PostgresResourceNodeInstanceStore(Npgsql.NpgsqlDataSource da remainingGathers = reader.GetInt32(1); return true; } + + /// + 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; + } } diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureApi.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureApi.cs new file mode 100644 index 0000000..37c5b2d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureApi.cs @@ -0,0 +1,28 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Dev-only route to reset prototype resource-node gathers for Bruno and manual QA. +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; + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureDtos.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureDtos.cs new file mode 100644 index 0000000..8d84924 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureDtos.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// JSON body for POST /game/__dev/resource-node-fixture (Bruno/manual QA). +public sealed class ResourceNodeFixtureRequest +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; init; } +} + +/// POST response for resource-node fixture apply. +public sealed class ResourceNodeFixtureResponse +{ + public bool Applied { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureOperations.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureOperations.cs new file mode 100644 index 0000000..0b311b9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeFixtureOperations.cs @@ -0,0 +1,21 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Resets prototype resource-node instance capacity for Bruno and manual QA. +public static class ResourceNodeFixtureOperations +{ + /// Sets each catalog node's remaining gathers to its maxGathers. + 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; + } +} diff --git a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs index 0ae7b8a..3b236a6 100644 --- a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs @@ -19,6 +19,9 @@ public sealed class GamePositionOptions /// When true, maps POST …/__dev/quest-fixture for Bruno/manual QA quest progress setup (also enabled in Development). public bool EnableQuestFixtureApi { get; set; } + /// When true, maps POST /game/__dev/resource-node-fixture to reset prototype node gathers (also enabled in Development/Testing). + public bool EnableResourceNodeFixtureApi { get; set; } + /// World position for the dev player at process start. public DefaultPositionOptions DefaultPosition { get; set; } = new(); diff --git a/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs index 1b48cd7..ae4d1da 100644 --- a/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs +++ b/server/NeonSprawl.Server/Game/Quests/IPlayerQuestStateStore.cs @@ -28,4 +28,7 @@ public interface IPlayerQuestStateStore /// First completion returns true; replays return false without changing . bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot); + + /// Removes progress row when present; idempotent when absent (dev Bruno fixture). + bool TryClearProgress(string playerId, string questId); } diff --git a/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs index 487b2e8..1caf1bb 100644 --- a/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs +++ b/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateStore.cs @@ -232,4 +232,22 @@ public sealed class InMemoryPlayerQuestStateStore(IOptions return true; } } + + /// + 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; + } + } } diff --git a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs index b8f4631..f17f315 100644 --- a/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs +++ b/server/NeonSprawl.Server/Game/Quests/PostgresPlayerQuestStateStore.cs @@ -240,6 +240,30 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo }, out snapshot); } + /// + 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( string player, string quest, diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs index c6d460a..e1d880e 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace NeonSprawl.Server.Game.Quests; /// POST body for POST /game/players/{{id}}/__dev/quest-fixture (NEO-137 Bruno/manual QA). @@ -5,10 +7,16 @@ public sealed class QuestFixtureRequest { public const int CurrentSchemaVersion = 1; + [JsonPropertyName("schemaVersion")] public int SchemaVersion { get; init; } /// Quest ids to mark completed via store activate+complete (no reward delivery). + [JsonPropertyName("completedQuestIds")] public IReadOnlyList CompletedQuestIds { get; init; } = []; + + /// Quest ids to clear from player progress (dev Bruno reset). + [JsonPropertyName("resetQuestIds")] + public IReadOnlyList ResetQuestIds { get; init; } = []; } /// POST response for quest fixture apply. diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs index 12ce7fd..d444d39 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs @@ -14,7 +14,7 @@ public static class QuestFixtureOperations IPlayerQuestStateStore progressStore, TimeProvider timeProvider) { - if (body.CompletedQuestIds.Count == 0) + if (body.CompletedQuestIds.Count == 0 && body.ResetQuestIds.Count == 0) { return true; } @@ -24,6 +24,19 @@ public static class QuestFixtureOperations 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) { if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId)) diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 0183e8f..85c3e7c 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -107,6 +107,13 @@ if (app.Environment.IsDevelopment() || app.MapQuestFixtureApi(); } +if (app.Environment.IsDevelopment() || + app.Environment.IsEnvironment("Testing") || + app.Configuration.GetValue("Game:EnableResourceNodeFixtureApi")) +{ + app.MapResourceNodeFixtureApi(); +} + app.MapTargetingApi(); app.MapHotbarLoadoutApi(); app.MapCooldownSnapshotApi(); diff --git a/server/NeonSprawl.Server/appsettings.Development.json b/server/NeonSprawl.Server/appsettings.Development.json index a6aaa2c..ee773c8 100644 --- a/server/NeonSprawl.Server/appsettings.Development.json +++ b/server/NeonSprawl.Server/appsettings.Development.json @@ -8,6 +8,7 @@ "Game": { "EnableMasteryFixtureApi": true, "EnableCombatTargetFixtureApi": true, - "EnableQuestFixtureApi": true + "EnableQuestFixtureApi": true, + "EnableResourceNodeFixtureApi": true } } From 64920792ee71b7103f9adc4a16a2a554660ee669 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 21:53:44 -0400 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20address=20Bruno=20self-reset=20rev?= =?UTF-8?q?iew=20=E2=80=94=20README,=20Postgres=20test,=20nits.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document resetQuestIds, resource-node fixture, and cooldown clear in server README; add TryClearProgress Postgres integration test; sync-review doc and Bruno require paths. --- .../ability-cast/Post cast happy.bru | 2 +- .../Post cast lock pulse defeat spine.bru | 2 +- .../scripts/bruno-dev-fixture-helper.js | 1 + .../2026-06-15-bruno-self-reset-fixtures.md | 69 +++++++++++++++++++ ...uestProgressPersistenceIntegrationTests.cs | 29 ++++++++ server/README.md | 12 +++- 6 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 docs/reviews/2026-06-15-bruno-self-reset-fixtures.md diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index 15b8fa2..53f6cee 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -5,7 +5,7 @@ meta { } script:pre-request { - const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); await resetPrototypeCombatTargets(bru); const axios = require("axios"); diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru index 6bde75c..4d10c1f 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -9,7 +9,7 @@ docs { } script:pre-request { - const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper"); + const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); await resetPrototypeCombatTargets(bru); const axios = require("axios"); diff --git a/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js index d3a9973..b998ced 100644 --- a/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js +++ b/bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js @@ -39,6 +39,7 @@ async function resetQuestProgress(bru, questIds) { } 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", diff --git a/docs/reviews/2026-06-15-bruno-self-reset-fixtures.md b/docs/reviews/2026-06-15-bruno-self-reset-fixtures.md new file mode 100644 index 0000000..a481ee9 --- /dev/null +++ b/docs/reviews/2026-06-15-bruno-self-reset-fixtures.md @@ -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. diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs index 8f4d352..08b3063 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/PlayerQuestProgressPersistenceIntegrationTests.cs @@ -90,6 +90,35 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg 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(); + 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(); + readBackExists = store.TryGetProgress(PlayerId, questId, out _); + } + + // Assert + Assert.False(readBackExists); + } + private async Task ResetQuestProgressTableAsync() { var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl"); diff --git a/server/README.md b/server/README.md index c299a40..ec01ae3 100644 --- a/server/README.md +++ b/server/README.md @@ -108,7 +108,13 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio 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) @@ -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" ``` -**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) @@ -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). +**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) **`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.