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.
pull/177/head
VinPropane 2026-06-15 21:50:22 -04:00
parent 0af21e002c
commit 95cf5d1f83
32 changed files with 574 additions and 17 deletions

View File

@ -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 \

View File

@ -5,6 +5,9 @@ meta {
} }
script:pre-request { script:pre-request {
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper");
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");

View File

@ -9,6 +9,9 @@ docs {
} }
script:pre-request { script:pre-request {
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper");
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");

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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);

View File

@ -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 {

View File

@ -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,
};

View File

@ -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,
}; };

View File

@ -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()
{ {

View File

@ -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()
{ {

View File

@ -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);
}
}

View File

@ -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));
}
} }

View File

@ -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 =>
{ {

View File

@ -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);
} }

View File

@ -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 _);
} }

View File

@ -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
{ {

View File

@ -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);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();

View File

@ -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);
} }

View File

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

View File

@ -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,

View File

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

View File

@ -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))

View File

@ -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();

View File

@ -8,6 +8,7 @@
"Game": { "Game": {
"EnableMasteryFixtureApi": true, "EnableMasteryFixtureApi": true,
"EnableCombatTargetFixtureApi": true, "EnableCombatTargetFixtureApi": true,
"EnableQuestFixtureApi": true "EnableQuestFixtureApi": true,
"EnableResourceNodeFixtureApi": true
} }
} }