NEO-138: clear delivery and audit on quest fixture reset.

resetQuestIds now clears IRewardDeliveryStore rows and quest-completion
audit deltas so Bruno re-runs re-apply rep grants after standing reset.
pull/178/head
VinPropane 2026-06-15 23:10:58 -04:00
parent 4ee29e867b
commit 6999eb3303
14 changed files with 192 additions and 4 deletions

View File

@ -6,7 +6,7 @@ meta {
docs { docs {
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15. 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). Success sibling: seq 11 Accept grid contract after operator chain (NEO-138 organic rep grant).
} }

View File

@ -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. 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. 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. 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 ## Nits

View File

@ -102,6 +102,31 @@ public sealed class InMemoryReputationDeltaStoreTests
Assert.Equal("delta-1", rows[0].Id); 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 InMemoryReputationDeltaStore CreateStore() => new();
private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) => private static ReputationDeltaRow CreateRow(string id, int delta, int resulting) =>

View File

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -224,4 +225,50 @@ public sealed class QuestFixtureApiTests
Assert.Equal(HttpStatusCode.OK, reset.StatusCode); Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
Assert.Equal(0, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing); 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<IRewardDeliveryStore>();
var auditStore = factory.Services.GetRequiredService<IReputationDeltaStore>();
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<IFactionStandingStore>(),
auditStore,
factory.Services.GetRequiredService<TimeProvider>());
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));
}
} }

View File

@ -99,6 +99,31 @@ public sealed class InMemoryRewardDeliveryStoreTests
Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey); 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() => private static RewardDeliveryEvent CreatePrototypeEvent() =>
new( new(
PlayerId, PlayerId,

View File

@ -766,6 +766,8 @@ public sealed class RewardRouterOperationsTests
deliveryEvent = winnerEvent; deliveryEvent = winnerEvent;
return true; return true;
} }
public bool TryClear(string playerId, string questId) => true;
} }
/// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary> /// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary>
@ -806,5 +808,7 @@ public sealed class RewardRouterOperationsTests
return filtered; return filtered;
} }
public bool TryClearQuestCompletionAudit(string playerId, string questId) => true;
} }
} }

View File

@ -11,4 +11,10 @@ public interface IReputationDeltaStore
/// <summary>Audit rows for one player ordered by <see cref="ReputationDeltaRow.RecordedAt"/> then <c>Id</c> ascending.</summary> /// <summary>Audit rows for one player ordered by <see cref="ReputationDeltaRow.RecordedAt"/> then <c>Id</c> ascending.</summary>
IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null); IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null);
/// <summary>
/// Dev fixture only — removes quest-completion audit rows for one quest (includes rollback rows).
/// Idempotent when no rows match.
/// </summary>
bool TryClearQuestCompletionAudit(string playerId, string questId);
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Factions; namespace NeonSprawl.Server.Game.Factions;
@ -60,6 +61,33 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
return query.ToArray(); return query.ToArray();
} }
/// <inheritdoc />
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) => private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 && row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&

View File

@ -1,5 +1,7 @@
namespace NeonSprawl.Server.Game.Factions; namespace NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
/// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary> /// <summary>PostgreSQL-backed append-only reputation delta audit log (NEO-135).</summary>
public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSource) : IReputationDeltaStore
{ {
@ -91,6 +93,33 @@ public sealed class PostgresReputationDeltaStore(Npgsql.NpgsqlDataSource dataSou
return rows; return rows;
} }
/// <inheritdoc />
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) => private static bool IsValidRow(ReputationDeltaRow row) =>
row.Id.Trim().Length > 0 && row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 && FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
@ -13,6 +14,7 @@ public static class QuestFixtureApi
(string id, QuestFixtureRequest? body, IPositionStateStore positions, (string id, QuestFixtureRequest? body, IPositionStateStore positions,
IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry, IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore, IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore, IReputationDeltaStore auditStore,
TimeProvider timeProvider) => TimeProvider timeProvider) =>
{ {
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
@ -33,6 +35,8 @@ public static class QuestFixtureApi
factionRegistry, factionRegistry,
progressStore, progressStore,
standingStore, standingStore,
deliveryStore,
auditStore,
timeProvider)) timeProvider))
{ {
return Results.NotFound(); return Results.NotFound();

View File

@ -1,6 +1,7 @@
namespace NeonSprawl.Server.Game.Quests; namespace NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary> /// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
public static class QuestFixtureOperations public static class QuestFixtureOperations
@ -16,6 +17,8 @@ public static class QuestFixtureOperations
IFactionDefinitionRegistry factionRegistry, IFactionDefinitionRegistry factionRegistry,
IPlayerQuestStateStore progressStore, IPlayerQuestStateStore progressStore,
IFactionStandingStore standingStore, IFactionStandingStore standingStore,
IRewardDeliveryStore deliveryStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider) TimeProvider timeProvider)
{ {
if (body.CompletedQuestIds.Count == 0 && if (body.CompletedQuestIds.Count == 0 &&
@ -41,6 +44,16 @@ public static class QuestFixtureOperations
{ {
return false; return false;
} }
if (!deliveryStore.TryClear(playerId, normalizedQuestId))
{
return false;
}
if (!auditStore.TryClearQuestCompletionAudit(playerId, normalizedQuestId))
{
return false;
}
} }
foreach (var factionId in body.ResetFactionIds) foreach (var factionId in body.ResetFactionIds)

View File

@ -7,4 +7,10 @@ public interface IRewardDeliveryStore
bool TryRecord(RewardDeliveryEvent deliveryEvent); bool TryRecord(RewardDeliveryEvent deliveryEvent);
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent); bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
/// <summary>
/// Dev fixture only — removes one delivery row so completion grants can re-apply on Bruno reset.
/// Idempotent when the row is already absent.
/// </summary>
bool TryClear(string playerId, string questId);
} }

View File

@ -62,9 +62,9 @@ public sealed class InMemoryRewardDeliveryStore : IRewardDeliveryStore
lock (keyLocks.GetOrAdd(key, _ => new object())) lock (keyLocks.GetOrAdd(key, _ => new object()))
{ {
var removed = eventsByKey.TryRemove(key, out _); _ = eventsByKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _); keyLocks.TryRemove(key, out _);
return removed; return true;
} }
} }
} }

View File

@ -109,7 +109,7 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md). 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): **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). - **`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. - **`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.