From 039672cdac63a8af0016ce6872da485c2a369d06 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:10:56 -0400 Subject: [PATCH] NEO-127: avoid clobbering winner perks on TryRecord race rollback Track perks unlocked per grant and remove only those on race loss instead of restoring a full pre-apply snapshot; re-run ReevaluateAfterLevelUp at post-rollback levels so a concurrent winner's path-auto unlocks survive. --- docs/plans/NEO-127-implementation-plan.md | 6 +-- docs/reviews/2026-06-07-NEO-127.md | 2 +- .../Rewards/RewardRouterOperationsTests.cs | 37 +++++++++++++++++ .../Game/Mastery/IPlayerPerkStateStore.cs | 3 ++ .../Mastery/InMemoryPlayerPerkStateStore.cs | 28 +++++++++++++ .../Mastery/PostgresPlayerPerkStateStore.cs | 39 ++++++++++++++++++ .../Game/Rewards/RewardRouterOperations.cs | 41 ++++++++++++++++--- .../Skills/SkillProgressionGrantOperations.cs | 31 ++++++++------ .../SkillProgressionGrantRollbackPoint.cs | 8 ---- server/README.md | 2 +- 10 files changed, 166 insertions(+), 31 deletions(-) delete mode 100644 server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantRollbackPoint.cs diff --git a/docs/plans/NEO-127-implementation-plan.md b/docs/plans/NEO-127-implementation-plan.md index f9acf25..cb84565 100644 --- a/docs/plans/NEO-127-implementation-plan.md +++ b/docs/plans/NEO-127-implementation-plan.md @@ -52,9 +52,9 @@ ## Implementation reconciliation (shipped) -- **Operations:** `RewardRouterOperations.TryDeliverQuestCompletion` — item pre-flight + apply, skill XP via `TryApplyGrant` (`mission_reward`), compensating item rollback + **`SkillProgressionGrantRollbackPoint`** restore (XP + perks) on **`TryRecord`** race loss, `IRewardDeliveryStore.TryRecord`. +- **Operations:** `RewardRouterOperations.TryDeliverQuestCompletion` — item pre-flight + apply, skill XP via `TryApplyGrant` (`mission_reward`), compensating item rollback + per-grant perk tracking + **`TryRevertAppliedSkillGrants`** on **`TryRecord`** race loss, `IRewardDeliveryStore.TryRecord`. - **Types:** `RewardDeliveryResult`, `RewardDeliveryReasonCodes`. -- **Tests:** 13 AAA tests in `RewardRouterOperationsTests` (…, TryRecord race rollback, concurrent race no double-grant, perk state restore on race loss after level-up). +- **Tests:** 14 AAA tests in `RewardRouterOperationsTests` (…, TryRecord race rollback, concurrent race no double-grant, perk revert on simulated race loss, concurrent level-up winner perk preserved). - **Docs:** `server/README.md` reward router section; E7.M2 module anchor + alignment register updated. ## Technical approach @@ -118,7 +118,7 @@ public static RewardDeliveryResult TryDeliverQuestCompletion( **Compensating item rollback** — private helper mirroring **`EncounterCompletionOperations.CompensatingRemoveGrants`**: reverse-order **`TryRemoveStack`** for applied item rows only (no skill XP undo). -**Compensating skill progression rollback** — **`SkillProgressionGrantOperations.CaptureRollbackPoint`** before skill apply; on **`TryRecord`** race loss, **`TryRestoreRollbackPoint`** reverses each applied skill XP row via negative delta and restores the captured perk snapshot (undoes level-up perk unlocks from the loser call without clobbering a concurrent winner's XP). +**Compensating skill progression rollback** — track perks unlocked per skill grant (`CollectPerksUnlockedSince`); on **`TryRecord`** race loss, **`TryRevertAppliedSkillGrants`** reverses each applied skill XP row via negative delta and removes only perks this call unlocked, then **`ReevaluateAfterLevelUp`** at post-rollback levels re-applies path-auto perks the winner's XP still qualifies for (avoids full snapshot restore clobbering a concurrent winner). ### 3. Skill XP source kind enforcement diff --git a/docs/reviews/2026-06-07-NEO-127.md b/docs/reviews/2026-06-07-NEO-127.md index c16be64..56f2460 100644 --- a/docs/reviews/2026-06-07-NEO-127.md +++ b/docs/reviews/2026-06-07-NEO-127.md @@ -54,7 +54,7 @@ None. dotnet test server/NeonSprawl.Server.Tests --filter RewardRouterOperationsTests ``` -All 13 tests pass (verified 2026-06-07; includes race rollback + perk snapshot restore on **`TryRecord`** loss). +All 14 tests pass (verified 2026-06-07; includes concurrent level-up winner perk preservation on **`TryRecord`** race loss). Optional before merge: diff --git a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs index 350f7ab..3c45a7b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs @@ -417,6 +417,43 @@ public sealed class RewardRouterOperationsTests Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _)); } + [Fact] + public async Task TryDeliverQuestCompletion_ShouldPreserveWinnerPerks_WhenConcurrentRecordRacesAfterLevelUp() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425)); + Assert.True(deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency").Success); + var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]); + var barrier = new Barrier(2); + RewardDeliveryResult result1 = default; + RewardDeliveryResult result2 = default; + + // Act + await Task.WhenAll( + Task.Run(() => + { + barrier.SignalAndWait(); + result1 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); + }), + Task.Run(() => + { + barrier.SignalAndWait(); + result2 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System); + })); + + // Assert + Assert.True(result1.Success); + Assert.True(result2.Success); + var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); + Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter)); + Assert.Equal(450, salvageAfter); + var perkAfter = deps.PerkStore.GetSnapshot(PlayerId); + Assert.Contains(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds); + Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _)); + } + private static RewardDeliveryResult Deliver( RewardRouterTestDependencies deps, string questId, diff --git a/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs index 29b5051..131157a 100644 --- a/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs @@ -14,6 +14,9 @@ public interface IPlayerPerkStateStore /// Adds perks not already unlocked; fails when player cannot be written. bool TryAddUnlockedPerks(string playerId, IEnumerable perkIds); + /// Removes unlocked perks present in the store; fails when player cannot be written. + bool TryRemoveUnlockedPerks(string playerId, IEnumerable perkIds); + /// Removes a branch pick (engine rollback when perk unlock fails after pick). bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex); diff --git a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs index 2736bfa..5883dea 100644 --- a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs @@ -91,6 +91,32 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o } } + /// + public bool TryRemoveUnlockedPerks(string playerId, IEnumerable perkIds) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return false; + } + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + foreach (var raw in perkIds) + { + var pid = raw.Trim(); + if (pid.Length == 0) + { + continue; + } + + inner.RemoveUnlockedPerk(pid); + } + + return true; + } + } + /// public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex) { @@ -185,6 +211,8 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId); + public void RemoveUnlockedPerk(string perkId) => unlockedPerks.Remove(perkId); + public bool RemoveBranchPick(string skillId, int tierIndex) => branchPicks.TryGetValue(skillId, out var tiers) && tiers.Remove(tierIndex); diff --git a/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs index 8617fed..d108260 100644 --- a/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs @@ -193,6 +193,45 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou return true; } + /// + public bool TryRemoveUnlockedPerks(string playerId, IEnumerable perkIds) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + return false; + } + + PostgresPerkStateBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, norm, tx)) + { + tx.Rollback(); + return false; + } + + foreach (var raw in perkIds) + { + var perkId = raw.Trim(); + if (perkId.Length == 0) + { + continue; + } + + using var delete = new Npgsql.NpgsqlCommand( + "DELETE FROM player_unlocked_perk WHERE player_id = @pid AND perk_id = @perk;", + conn, + tx); + delete.Parameters.AddWithValue("pid", norm); + delete.Parameters.AddWithValue("perk", perkId); + delete.ExecuteNonQuery(); + } + + tx.Commit(); + return true; + } + /// public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex) { diff --git a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs index cf4b53c..32cb573 100644 --- a/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs +++ b/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs @@ -99,13 +99,11 @@ public static class RewardRouterOperations appliedItems.Add(new RewardItemGrantApplied(grant.ItemId, grant.Quantity)); } - var progressionRollbackPoint = SkillProgressionGrantOperations.CaptureRollbackPoint( - playerId, - skillProgressionStore, - perkStore); + var perksUnlockedByThisCall = new HashSet(StringComparer.Ordinal); var appliedSkillXp = new List(skillGrants.Count); foreach (var grant in skillGrants) { + var perkBeforeGrant = perkStore.GetSnapshot(playerId); var skillOutcome = SkillProgressionGrantOperations.TryApplyGrant( playerId, grant.SkillId, @@ -130,6 +128,10 @@ public static class RewardRouterOperations } appliedSkillXp.Add(new RewardSkillXpGrantApplied(grant.SkillId, grant.Amount)); + SkillProgressionGrantOperations.CollectPerksUnlockedSince( + perkBeforeGrant, + perkStore.GetSnapshot(playerId), + perksUnlockedByThisCall); } var deliveredAt = timeProvider.GetUtcNow(); @@ -149,12 +151,18 @@ public static class RewardRouterOperations // Lost TryRecord race after apply — rollback this call's grants (mirror // EncounterCompletionOperations when TryMarkCompleted returns false). CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore); - _ = SkillProgressionGrantOperations.TryRestoreRollbackPoint( + _ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants( playerId, - progressionRollbackPoint, appliedSkillXp, + perksUnlockedByThisCall, skillProgressionStore, perkStore); + ResyncPathAutoPerksAfterRaceRollback( + playerId, + appliedSkillXp, + skillProgressionStore, + levelCurve, + perkUnlockEngine); if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent)) { @@ -164,6 +172,27 @@ public static class RewardRouterOperations return Deny(RewardDeliveryReasonCodes.AlreadyDelivered); } + private static void ResyncPathAutoPerksAfterRaceRollback( + string playerId, + IReadOnlyList appliedSkillXp, + IPlayerSkillProgressionStore skillProgressionStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine) + { + if (appliedSkillXp.Count == 0) + { + return; + } + + var xpBySkill = skillProgressionStore.GetXpTotals(playerId); + foreach (var grant in appliedSkillXp) + { + xpBySkill.TryGetValue(grant.SkillId, out var totalXp); + var level = levelCurve.LevelFromTotalXp(totalXp); + _ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, grant.SkillId, level); + } + } + private static string MapInventoryReason(string? reasonCode) => reasonCode switch { diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs index 401167a..0d27599 100644 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs @@ -103,20 +103,26 @@ public static class SkillProgressionGrantOperations }); } - /// Captures skill XP totals and perk snapshot before a grant batch for compensating rollback. - public static SkillProgressionGrantRollbackPoint CaptureRollbackPoint( - string playerId, - IPlayerSkillProgressionStore xpStore, - IPlayerPerkStateStore perkStore) => - new( - new Dictionary(xpStore.GetXpTotals(playerId), StringComparer.Ordinal), - perkStore.GetSnapshot(playerId)); + /// Collects perk ids unlocked between two snapshots (this batch only). + public static void CollectPerksUnlockedSince( + PerkStateSnapshot before, + PerkStateSnapshot after, + ISet sink) + { + foreach (var perkId in after.UnlockedPerkIds) + { + if (!before.UnlockedPerkIds.Contains(perkId)) + { + sink.Add(perkId); + } + } + } - /// Restores skill XP and perk state captured before a failed grant batch commit. - public static bool TryRestoreRollbackPoint( + /// Reverts skill XP grants and perks unlocked during this failed batch commit. + public static bool TryRevertAppliedSkillGrants( string playerId, - SkillProgressionGrantRollbackPoint rollbackPoint, IReadOnlyList appliedGrants, + IReadOnlyCollection perksUnlockedByBatch, IPlayerSkillProgressionStore xpStore, IPlayerPerkStateStore perkStore) { @@ -129,6 +135,7 @@ public static class SkillProgressionGrantOperations } } - return perkStore.TryRestoreSnapshot(playerId, rollbackPoint.PerkState); + return perksUnlockedByBatch.Count == 0 + || perkStore.TryRemoveUnlockedPerks(playerId, perksUnlockedByBatch); } } diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantRollbackPoint.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantRollbackPoint.cs deleted file mode 100644 index 4978e27..0000000 --- a/server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantRollbackPoint.cs +++ /dev/null @@ -1,8 +0,0 @@ -using NeonSprawl.Server.Game.Mastery; - -namespace NeonSprawl.Server.Game.Skills; - -/// Captured skill XP + perk state before a grant apply batch (NEO-127 compensating rollback). -public readonly record struct SkillProgressionGrantRollbackPoint( - IReadOnlyDictionary SkillXpTotals, - PerkStateSnapshot PerkState); diff --git a/server/README.md b/server/README.md index 3f4e2ae..8b46293 100644 --- a/server/README.md +++ b/server/README.md @@ -297,7 +297,7 @@ Encounter loot remains on **`IEncounterCompleteEventStore`** (E5.M3); quest comp | **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Skill XP grant denied. | | **`progression_store_missing`** | Skill progression store could not apply XP delta. | -Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRestoreRollbackPoint`** — negative XP delta per applied skill row plus restore of the captured pre-apply perk snapshot (reverts level-up perk unlocks from the loser without clobbering a concurrent winner's XP) — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md). +Apply order: **items first**, then **skill XP**. Inventory deny or skill deny fails closed without store record (compensating item rollback on partial apply or skill failure after items). Idempotent replay checks **`TryGet`** before apply. If grants succeed but **`TryRecord`** returns `false` (concurrent race), this call **rolls back** its item grants and calls **`SkillProgressionGrantOperations.TryRevertAppliedSkillGrants`** — negative XP delta per applied skill row plus removal of perks unlocked during this call only — then **`ReevaluateAfterLevelUp`** at post-rollback levels so a concurrent winner's path-auto unlocks are preserved — then returns the existing store event (mirror **`EncounterCompletionOperations`** compensating rollback when **`TryMarkCompleted`** loses). Quest-state wiring lands in NEO-128. Plan: [NEO-127 implementation plan](../../docs/plans/NEO-127-implementation-plan.md). ## Encounter definitions (NEO-103)