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 9978291..7cbfb3c 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 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). + 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). Quest fixture resetQuestIds also clears reward delivery + audit rows for reset quests. Success sibling: seq 11 Accept grid contract after operator chain (NEO-138 organic rep grant). } diff --git a/docs/reviews/2026-06-15-NEO-138.md b/docs/reviews/2026-06-15-NEO-138.md index c43c9be..1c30c06 100644 --- a/docs/reviews/2026-06-15-NEO-138.md +++ b/docs/reviews/2026-06-15-NEO-138.md @@ -35,6 +35,7 @@ This branch lands **E7M3-06**: **`RewardRouterOperations.TryDeliverQuestCompleti 2. ~~**Assert audit row on operator-chain router happy path.** Plan scenario “audit row queryable” — add `IReputationDeltaStore` lookup for `{idempotencyKey}:rep:{factionId}` in `TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle` (or the dedicated rep test).~~ **Done.** Audit row asserted in operator-chain bundle test. 3. ~~**Document auto-complete on grid-contract accept in tests/README.** Integration test name says “Accept” but the meaningful gate check is standing ≥ 15; the returned snapshot is **`completed`** because the kit is already in inventory. A one-line comment in `QuestStateOperationsTests` and Bruno **docs** block would prevent future “active vs completed” confusion.~~ **Done.** Comment in `QuestStateOperationsTests`; Bruno docs block updated. 4. ~~**Optional: multi-row rep deny rollback test.** Plan lists rep deny after items with audit append failure on the second rep row; only **`unknown_faction`** rollback is covered today. Consider a store stub that fails **`TryAppend`** on the second row to lock compensating rollback across grant types.~~ **Done.** `TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails` with forward-rep-only audit stub. +5. ~~**Bugbot: fixture reset skips delivery store.** `resetQuestIds` cleared quest progress and faction standing but not **`IRewardDeliveryStore`** / audit rows, so repeated Bruno runs skipped rep re-apply via idempotent delivery (and duplicate audit ids).~~ **Done.** `resetQuestIds` clears delivery + quest-completion audit rows. ## Nits diff --git a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs index 0ba538c..3f2a3d9 100644 --- a/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Factions/InMemoryReputationDeltaStoreTests.cs @@ -102,6 +102,31 @@ public sealed class InMemoryReputationDeltaStoreTests Assert.Equal("delta-1", rows[0].Id); } + [Fact] + public void TryClearQuestCompletionAudit_ShouldRemoveMatchingRows_WhenPresent() + { + // Arrange + var store = CreateStore(); + Assert.True(store.TryAppend(CreateRow("delta-forward", 15, 15))); + Assert.True(store.TryAppend(CreateRow("delta-rollback", -15, 0) with + { + Id = "delta-forward:rollback", + })); + Assert.True(store.TryAppend(CreateRow("delta-other-quest", 10, 10) with + { + Id = "other-quest-delta", + SourceId = "prototype_quest_gather_intro", + })); + // Act + var cleared = store.TryClearQuestCompletionAudit(PlayerId, "prototype_quest_operator_chain"); + // Assert + Assert.True(cleared); + Assert.DoesNotContain( + store.GetDeltasForPlayer(PlayerId), + row => row.SourceId == "prototype_quest_operator_chain"); + Assert.Single(store.GetDeltasForPlayer(PlayerId)); + } + private static InMemoryReputationDeltaStore CreateStore() => new(); private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) => diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs index 936096e..69a7ab1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestFixtureApiTests.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Tests; using Xunit; @@ -224,4 +225,50 @@ public sealed class QuestFixtureApiTests Assert.Equal(HttpStatusCode.OK, reset.StatusCode); Assert.Equal(0, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing); } + + [Fact] + public async Task PostQuestFixture_ShouldClearRewardDeliveryAndAudit_WhenResetQuestIdsProvided() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var deliveryStore = factory.Services.GetRequiredService(); + var auditStore = factory.Services.GetRequiredService(); + const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; + var apply = ReputationOperations.TryApplyDelta( + DevPlayer, + gridFactionId, + 15, + RewardDeliveryIds.MakeReputationDeltaId( + RewardDeliveryIds.MakeIdempotencyKey(DevPlayer, ChainQuestId), + gridFactionId), + ReputationDeltaSourceKinds.QuestCompletion, + ChainQuestId, + factory.Services.GetRequiredService(), + auditStore, + factory.Services.GetRequiredService()); + Assert.True(apply.Success); + Assert.True(deliveryStore.TryRecord(new RewardDeliveryEvent( + DevPlayer, + ChainQuestId, + DateTimeOffset.UtcNow, + [], + [], + [new RewardReputationGrantApplied(gridFactionId, 15)], + RewardDeliveryIds.MakeIdempotencyKey(DevPlayer, ChainQuestId)))); + + // Act + var reset = await client.PostAsJsonAsync( + FixturePath, + new QuestFixtureRequest + { + SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion, + ResetQuestIds = [ChainQuestId], + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + Assert.False(deliveryStore.TryGet(DevPlayer, ChainQuestId, out _)); + Assert.Empty(auditStore.GetDeltasForPlayer(DevPlayer)); + } } diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs index f2c691c..e39699b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs @@ -99,6 +99,31 @@ public sealed class InMemoryRewardDeliveryStoreTests Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey); } + [Fact] + public void TryClear_ShouldRemoveDeliveryRow_WhenPresent() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + var deliveryEvent = CreatePrototypeEvent(); + Assert.True(store.TryRecord(deliveryEvent)); + // Act + var cleared = store.TryClear(PlayerId, QuestId); + // Assert + Assert.True(cleared); + Assert.False(store.TryGet(PlayerId, QuestId, out _)); + } + + [Fact] + public void TryClear_ShouldReturnTrue_WhenRowAlreadyAbsent() + { + // Arrange + var store = new InMemoryRewardDeliveryStore(); + // Act + var cleared = store.TryClear(PlayerId, QuestId); + // Assert + Assert.True(cleared); + } + private static RewardDeliveryEvent CreatePrototypeEvent() => new( PlayerId, diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs index 7461882..fa40ef2 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs @@ -766,6 +766,8 @@ public sealed class RewardRouterOperationsTests deliveryEvent = winnerEvent; return true; } + + public bool TryClear(string playerId, string questId) => true; } /// Second forward rep audit append fails; rollback appends still succeed. @@ -806,5 +808,7 @@ public sealed class RewardRouterOperationsTests return filtered; } + + public bool TryClearQuestCompletionAudit(string playerId, string questId) => true; } } diff --git a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs index 13ae723..595cf1a 100644 --- a/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/IReputationDeltaStore.cs @@ -11,4 +11,10 @@ public interface IReputationDeltaStore /// Audit rows for one player ordered by then Id ascending. IReadOnlyList GetDeltasForPlayer(string playerId, int? limit = null); + + /// + /// Dev fixture only — removes quest-completion audit rows for one quest (includes rollback rows). + /// Idempotent when no rows match. + /// + bool TryClearQuestCompletionAudit(string playerId, string questId); } diff --git a/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs index fcfff4f..bc226c1 100644 --- a/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/InMemoryReputationDeltaStore.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Factions; @@ -60,6 +61,33 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore return query.ToArray(); } + /// + public bool TryClearQuestCompletionAudit(string playerId, string questId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var sourceId = RewardDeliveryIds.NormalizeQuestId(questId); + if (player.Length == 0 || sourceId.Length == 0) + { + return false; + } + + var idsToRemove = rowsById.Values + .Where(row => + string.Equals(row.PlayerId, player, StringComparison.Ordinal) && + string.Equals(row.SourceKind, ReputationDeltaSourceKinds.QuestCompletion, StringComparison.Ordinal) && + string.Equals(row.SourceId, sourceId, StringComparison.Ordinal)) + .Select(static row => row.Id) + .ToArray(); + + foreach (var id in idsToRemove) + { + rowsById.TryRemove(id, out _); + idLocks.TryRemove(id, out _); + } + + return true; + } + private static bool IsValidRow(ReputationDeltaRow row) => row.Id.Trim().Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && diff --git a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs index 14bdd8f..96e6aaa 100644 --- a/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs +++ b/server/NeonSprawl.Server/Game/Factions/PostgresReputationDeltaStore.cs @@ -1,5 +1,7 @@ namespace NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Rewards; + /// PostgreSQL-backed append-only reputation delta audit log (NEO-135). public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore { @@ -91,6 +93,33 @@ public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSou return rows; } + /// + public bool TryClearQuestCompletionAudit(string playerId, string questId) + { + var player = FactionStandingIds.NormalizePlayerId(playerId); + var sourceId = RewardDeliveryIds.NormalizeQuestId(questId); + if (player.Length == 0 || sourceId.Length == 0) + { + return false; + } + + PostgresReputationDeltaBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var cmd = new Npgsql.NpgsqlCommand( + """ + DELETE FROM reputation_delta_audit + WHERE player_id = @pid + AND source_kind = @kind + AND source_id = @source_id; + """, + conn); + cmd.Parameters.AddWithValue("pid", player); + cmd.Parameters.AddWithValue("kind", ReputationDeltaSourceKinds.QuestCompletion); + cmd.Parameters.AddWithValue("source_id", sourceId); + cmd.ExecuteNonQuery(); + return true; + } + private static bool IsValidRow(ReputationDeltaRow row) => row.Id.Trim().Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs index 7ca8934..d5e9ce7 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureApi.cs @@ -1,5 +1,6 @@ using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Quests; @@ -13,6 +14,7 @@ public static class QuestFixtureApi (string id, QuestFixtureRequest? body, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry, IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore, + IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore, TimeProvider timeProvider) => { if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion) @@ -33,6 +35,8 @@ public static class QuestFixtureApi factionRegistry, progressStore, standingStore, + deliveryStore, + auditStore, timeProvider)) { return Results.NotFound(); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs index fc77924..90b7b1b 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestFixtureOperations.cs @@ -1,6 +1,7 @@ namespace NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Factions; +using NeonSprawl.Server.Game.Rewards; /// Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA). public static class QuestFixtureOperations @@ -16,6 +17,8 @@ public static class QuestFixtureOperations IFactionDefinitionRegistry factionRegistry, IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore, + IRewardDeliveryStore deliveryStore, + IReputationDeltaStore auditStore, TimeProvider timeProvider) { if (body.CompletedQuestIds.Count == 0 && @@ -41,6 +44,16 @@ public static class QuestFixtureOperations { return false; } + + if (!deliveryStore.TryClear(playerId, normalizedQuestId)) + { + return false; + } + + if (!auditStore.TryClearQuestCompletionAudit(playerId, normalizedQuestId)) + { + return false; + } } foreach (var factionId in body.ResetFactionIds) diff --git a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs index aa8d579..f7c4860 100644 --- a/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs +++ b/server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs @@ -7,4 +7,10 @@ public interface IRewardDeliveryStore bool TryRecord(RewardDeliveryEvent deliveryEvent); bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); + + /// + /// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset. + /// Idempotent when the row is already absent. + /// + bool TryClear(string playerId, string questId); } diff --git a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs index faf49b4..b9a89ed 100644 --- a/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs +++ b/server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs @@ -62,9 +62,9 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore lock (keyLocks.GetOrAdd(key, _ => new object())) { - var removed = eventsByKey.TryRemove(key, out _); + _ = eventsByKey.TryRemove(key, out _); keyLocks.TryRemove(key, out _); - return removed; + return true; } } } diff --git a/server/README.md b/server/README.md index 7677746..8ea80f3 100644 --- a/server/README.md +++ b/server/README.md @@ -109,7 +109,7 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md). **Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined): -- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent). +- **`resetQuestIds`** — deletes each quest's progress row for the player and clears matching **`IRewardDeliveryStore`** + quest-completion **`IReputationDeltaStore`** audit rows (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.