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 } }