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
parent
4ee29e867b
commit
6999eb3303
|
|
@ -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).
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue