NEO-138: quest-fixture resetFactionIds for gate deny Bruno.

Clear prototype faction standing in seq 10 pre-request so stale +15 from
prior seq 11 runs does not pass the grid contract gate locally.
pull/178/head
VinPropane 2026-06-15 23:00:21 -04:00
parent 2581677dd5
commit 4ee29e867b
10 changed files with 160 additions and 8 deletions

View File

@ -6,7 +6,7 @@ meta {
docs {
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15.
Pre-request resets grid contract progress (stale completed rows from prior local seq 11 runs), then marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing stays 0).
Pre-request resets grid contract progress and prototype faction standing (stale state from prior seq 11 runs), then marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing 0).
Success sibling: seq 11 Accept grid contract after operator chain (NEO-138 organic rep grant).
}
@ -17,12 +17,17 @@ script:pre-request {
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const chainQuestId = "prototype_quest_operator_chain";
const gridContractQuestId = "prototype_quest_grid_contract";
const prototypeFactionIds = [
"prototype_faction_grid_operators",
"prototype_faction_rust_collective",
];
const fixture = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
{
schemaVersion: 1,
resetQuestIds: [gridContractQuestId],
resetFactionIds: prototypeFactionIds,
completedQuestIds: [chainQuestId],
},
{ ...jsonHeaders, validateStatus: () => true },

View File

@ -134,6 +134,20 @@ public sealed class InMemoryFactionStandingStoreTests
Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryClearStanding_ShouldReturnZeroRead_WhenRowRemoved()
{
// Arrange
var store = CreateStore();
store.SeedRawStandingForTests(PlayerId, GridFactionId, 15);
Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing);
// Act
var cleared = store.TryClearStanding(PlayerId, GridFactionId);
// Assert
Assert.True(cleared);
Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing);
}
[Fact]
public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown()
{

View File

@ -3,6 +3,7 @@ using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
@ -188,4 +189,39 @@ public sealed class QuestFixtureApiTests
Assert.NotNull(body);
Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status));
}
[Fact]
public async Task PostQuestFixture_ShouldClearFactionStanding_WhenResetFactionIdsProvided()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
var apply = ReputationOperations.TryApplyDelta(
DevPlayer,
gridFactionId,
15,
"fixture-seed-standing",
ReputationDeltaSourceKinds.QuestCompletion,
ChainQuestId,
standingStore,
factory.Services.GetRequiredService<IReputationDeltaStore>(),
factory.Services.GetRequiredService<TimeProvider>());
Assert.True(apply.Success);
Assert.Equal(15, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing);
// Act
var reset = await client.PostAsJsonAsync(
FixturePath,
new QuestFixtureRequest
{
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
ResetFactionIds = [gridFactionId],
});
// Assert
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.Equal(0, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing);
}
}

View File

@ -20,4 +20,10 @@ public interface IFactionStandingStore
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
/// </summary>
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
/// <summary>
/// Removes persisted standing for one faction (missing row reads as neutral <c>0</c>).
/// Dev fixture / Bruno reset only — not for gameplay rollback.
/// </summary>
bool TryClearStanding(string playerId, string factionId);
}

View File

@ -99,6 +99,35 @@ public sealed class InMemoryFactionStandingStore(
}
}
/// <inheritdoc />
public bool TryClearStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0)
{
return false;
}
if (!factionRegistry.TryGetDefinition(faction, out _))
{
return false;
}
if (!CanWritePlayer(player))
{
return false;
}
var key = FactionStandingIds.MakeStandingKey(player, faction);
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
standingByKey.TryRemove(key, out _);
}
return true;
}
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
new(false, reasonCode, 0);

View File

@ -136,6 +136,40 @@ public sealed class PostgresFactionStandingStore(
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
}
/// <inheritdoc />
public bool TryClearStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0)
{
return false;
}
if (!factionRegistry.TryGetDefinition(faction, out _))
{
return false;
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, player))
{
return false;
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
DELETE FROM player_faction_standing
WHERE player_id = @pid AND faction_id = @fid;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("fid", faction);
cmd.ExecuteNonQuery();
return true;
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
{
using var cmd = new Npgsql.NpgsqlCommand(

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
@ -10,7 +11,8 @@ public static class QuestFixtureApi
app.MapPost(
"/game/players/{id}/__dev/quest-fixture",
(string id, QuestFixtureRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
TimeProvider timeProvider) =>
{
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
@ -28,7 +30,9 @@ public static class QuestFixtureApi
trimmedId,
body,
questRegistry,
factionRegistry,
progressStore,
standingStore,
timeProvider))
{
return Results.NotFound();

View File

@ -17,6 +17,10 @@ public sealed class QuestFixtureRequest
/// <summary>Quest ids to clear from player progress (dev Bruno reset).</summary>
[JsonPropertyName("resetQuestIds")]
public IReadOnlyList<string> ResetQuestIds { get; init; } = [];
/// <summary>Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0).</summary>
[JsonPropertyName("resetFactionIds")]
public IReadOnlyList<string> ResetFactionIds { get; init; } = [];
}
/// <summary>POST response for quest fixture apply.</summary>

View File

@ -1,5 +1,7 @@
namespace NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Factions;
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
public static class QuestFixtureOperations
{
@ -11,10 +13,14 @@ public static class QuestFixtureOperations
string playerId,
QuestFixtureRequest body,
IQuestDefinitionRegistry questRegistry,
IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore,
IFactionStandingStore standingStore,
TimeProvider timeProvider)
{
if (body.CompletedQuestIds.Count == 0 && body.ResetQuestIds.Count == 0)
if (body.CompletedQuestIds.Count == 0 &&
body.ResetQuestIds.Count == 0 &&
body.ResetFactionIds.Count == 0)
{
return true;
}
@ -37,6 +43,19 @@ public static class QuestFixtureOperations
}
}
foreach (var factionId in body.ResetFactionIds)
{
if (!factionRegistry.TryGetDefinition(factionId, out var definition) || definition is null)
{
return false;
}
if (!standingStore.TryClearStanding(playerId, definition.Id))
{
return false;
}
}
foreach (var questId in body.CompletedQuestIds)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))

View File

@ -104,15 +104,16 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract (clears stale **`completed`** from prior seq **11** runs) plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept.
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract, **`resetFactionIds`** for both prototype factions (clears stale standing from prior seq **11** runs), plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept.
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** 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).
**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 (resets run before **`completedQuestIds`** when combined):
- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent).
- **`resetFactionIds`** — clears each faction's standing row for the player (missing row reads as **0**; idempotent when absent).
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged unless **`resetFactionIds`** also sent). 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` ( **`completedQuestIds`** ); order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
**400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`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`**.