From 4ee29e867bb9ef04deaa5588849cdcd338ffcc54 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 15 Jun 2026 23:00:21 -0400 Subject: [PATCH] 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. --- ...Accept grid contract faction gate deny.bru | 7 +++- .../InMemoryFactionStandingStoreTests.cs | 14 ++++++++ .../Game/Quests/QuestFixtureApiTests.cs | 36 +++++++++++++++++++ .../Game/Factions/IFactionStandingStore.cs | 6 ++++ .../Factions/InMemoryFactionStandingStore.cs | 29 +++++++++++++++ .../Factions/PostgresFactionStandingStore.cs | 34 ++++++++++++++++++ .../Game/Quests/QuestFixtureApi.cs | 6 +++- .../Game/Quests/QuestFixtureDtos.cs | 4 +++ .../Game/Quests/QuestFixtureOperations.cs | 21 ++++++++++- server/README.md | 11 +++--- 10 files changed, 160 insertions(+), 8 deletions(-) diff --git a/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru b/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru index 5e623e7..9978291 100644 --- a/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru +++ b/bruno/neon-sprawl-server/quest-progress/Accept grid contract faction gate deny.bru @@ -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 }, diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs index 1afc3ca..f418cb6 100644 --- a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryFactionStandingStoreTests.cs @@ -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() { diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs index 89a2f59..936096e 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs @@ -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(); + const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + var apply = ReputationOperations.TryApplyDelta( + DevPlayer, + gridFactionId, + 15, + "fixture-seed-standing", + ReputationDeltaSourceKinds.QuestCompletion, + ChainQuestId, + standingStore, + factory.Services.GetRequiredService(), + factory.Services.GetRequiredService()); + 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); + } } diff --git a/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs index 2d5086c..d0c7bfe 100644 --- a/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/IFactionStandingStore.cs @@ -20,4 +20,10 @@ public interface IFactionStandingStore /// Does not append audit rows — is orchestrated by NEO-136. /// FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount); + + /// + /// Removes persisted standing for one faction (missing row reads as neutral 0). + /// Dev fixture / Bruno reset only — not for gameplay rollback. + /// + bool TryClearStanding(string playerId, string factionId); } diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs index 4f814bd..26db61b 100644 --- a/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryFactionStandingStore.cs @@ -99,6 +99,35 @@ public sealed class InMemoryFactionStandingStore( } } + /// + 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); diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs index fdb3ffd..6595d58 100644 --- a/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/PostgresFactionStandingStore.cs @@ -136,6 +136,40 @@ public sealed class PostgresFactionStandingStore( return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding); } + /// + 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( diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs index 74ccab9..7ca8934 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs @@ -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(); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs index e1d880e..3f8141e 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureDtos.cs @@ -17,6 +17,10 @@ public sealed class QuestFixtureRequest /// Quest ids to clear from player progress (dev Bruno reset). [JsonPropertyName("resetQuestIds")] public IReadOnlyList ResetQuestIds { get; init; } = []; + + /// Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0). + [JsonPropertyName("resetFactionIds")] + public IReadOnlyList ResetFactionIds { 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 d444d39..fc77924 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs @@ -1,5 +1,7 @@ namespace NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Factions; + /// Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA). 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)) diff --git a/server/README.md b/server/README.md index 28f06af..7677746 100644 --- a/server/README.md +++ b/server/README.md @@ -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`**.