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 {
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).
}

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

View File

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

View File

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

View File

@ -766,6 +766,8 @@ public sealed class RewardRouterOperationsTests
deliveryEvent = winnerEvent;
return true;
}
public bool TryClear(string playerId, string questId) => true;
}
/// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary>
@ -806,5 +808,7 @@ public sealed class RewardRouterOperationsTests
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>
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 NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Factions;
@ -60,6 +61,33 @@ public sealed class InMemoryReputationDeltaStore : IReputationDeltaStore
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) =>
row.Id.Trim().Length > 0 &&
FactionStandingIds.NormalizePlayerId(row.PlayerId).Length > 0 &&

View File

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

View File

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

View File

@ -1,6 +1,7 @@
namespace NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Rewards;
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
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)

View File

@ -7,4 +7,10 @@ public interface IRewardDeliveryStore
bool TryRecord(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()))
{
var removed = eventsByKey.TryRemove(key, out _);
_ = eventsByKey.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).
**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.