From 6f7fcfe28e95680b449ea57b1dcc2547357bee73 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 4 Jun 2026 18:14:14 -0400 Subject: [PATCH] feat(battle-node): per-battle master seed + node-side deck shuffle GetOrSeedDeckMap now seeds from a Fisher-Yates shuffle of the deck keyed by the per-battle MasterSeed, so the reveal map and the wire selfDeck share one shuffled order. Updated the existing build-order test to the shuffle semantics. Co-Authored-By: Claude Opus 4.8 --- .../Sessions/Dispatch/BattleSessionState.cs | 36 +++++++++++-- .../Sessions/BattleSessionStateTests.cs | 53 +++++++++++++++++-- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs index 6442a0e..ca6f784 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs @@ -1,3 +1,4 @@ +using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Sessions; namespace SVSim.BattleNode.Sessions.Dispatch; @@ -9,6 +10,34 @@ namespace SVSim.BattleNode.Sessions.Dispatch; /// map via ; a reveal-gate set is still future. internal sealed class BattleSessionState { + /// The one random value chosen per battle. Every per-battle RNG (shared effect seed, + /// each side's deck shuffle + idxChangeSeed) derives from it via . + /// Logged at session start so a battle's randomness is reproducible (future replay). + public int MasterSeed { get; } + + /// Test hook — production uses the random default. + public BattleSessionState(int? masterSeed = null) => + MasterSeed = masterSeed ?? Random.Shared.Next(); + + private readonly Dictionary> _shuffledDecks = new(); + + /// This side's deck, shuffled deterministically from + /// (Fisher–Yates). Cached per side. Both the wire selfDeck (Matched) and the reveal map + /// () read this, so they share one shuffled order. + public IReadOnlyList GetShuffledDeck(IBattleParticipant side) + { + if (_shuffledDecks.TryGetValue(side, out var cached)) return cached; + var deck = side.Context.SelfDeckCardIds.ToArray(); + var rng = new Random(BattleSeeds.DeckShuffle(MasterSeed, side.ViewerId)); + for (var i = deck.Length - 1; i > 0; i--) + { + var j = rng.Next(i + 1); + (deck[i], deck[j]) = (deck[j], deck[i]); + } + _shuffledDecks[side] = deck; + return deck; + } + public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; public Dictionary PostSwapHands { get; } = new(); @@ -17,14 +46,15 @@ internal sealed class BattleSessionState /// from add ops via ). public Dictionary> IdxToCardId { get; } = new(); - /// The sender's idx->cardId map, seeding it from its on first - /// use. BuildPlayerDeck assigns deck idx = position+1, so entry (i+1) -> cardIds[i]. + /// The sender's idx->cardId map, seeding it from its order on + /// first use. Deck idx = position+1 in the shuffled order, so entry (i+1) -> shuffledDeck[i]. The + /// wire selfDeck (Matched) is built from the same shuffled order, so the two agree. public IReadOnlyDictionary GetOrSeedDeckMap(IBattleParticipant side) { if (!IdxToCardId.TryGetValue(side, out var map)) { map = new Dictionary(); - var deck = side.Context.SelfDeckCardIds; + var deck = GetShuffledDeck(side); for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i]; IdxToCardId[side] = map; } diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs index 873984a..36faa1f 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs @@ -27,17 +27,21 @@ public class BattleSessionStateTests FieldId: 0, IsOfficial: 0, BattleType: 11); [Test] - public void GetOrSeedDeckMap_maps_idx_1based_to_deck_cardIds() + public void GetOrSeedDeckMap_maps_idx_1based_to_the_shuffled_order() { - var state = new BattleSessionState(); + // The map seeds from GetShuffledDeck, not raw build order. idx (i+1) -> shuffledDeck[i], + // and the set of cardIds is unchanged (1..3 present, 4 absent). + var state = new BattleSessionState(masterSeed: 12345); var p = new StubParticipant(1, Ctx(900L, 901L, 902L)); + var shuffled = state.GetShuffledDeck(p); var map = state.GetOrSeedDeckMap(p); - Assert.That(map[1], Is.EqualTo(900L)); - Assert.That(map[2], Is.EqualTo(901L)); - Assert.That(map[3], Is.EqualTo(902L)); + Assert.That(map[1], Is.EqualTo(shuffled[0])); + Assert.That(map[2], Is.EqualTo(shuffled[1])); + Assert.That(map[3], Is.EqualTo(shuffled[2])); Assert.That(map.ContainsKey(4), Is.False); + Assert.That(new[] { map[1], map[2], map[3] }, Is.EquivalentTo(new[] { 900L, 901L, 902L })); } [Test] @@ -47,4 +51,43 @@ public class BattleSessionStateTests var p = new StubParticipant(1, Ctx(900L)); Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p))); } + + [Test] + public void GetShuffledDeck_is_a_permutation_of_the_input() + { + var state = new BattleSessionState(masterSeed: 12345); + var p = new StubParticipant(1001, Ctx(DistinctDeck())); + + Assert.That(state.GetShuffledDeck(p), Is.EquivalentTo(DistinctDeck()), + "same multiset of cards, just reordered"); + } + + [Test] + public void GetShuffledDeck_actually_reorders_a_distinct_deck() + { + var state = new BattleSessionState(masterSeed: 12345); + var p = new StubParticipant(1001, Ctx(DistinctDeck())); + + Assert.That(state.GetShuffledDeck(p), Is.Not.EqualTo(DistinctDeck()), + "a 30-card distinct deck should not survive the shuffle in original order"); + } + + [Test] + public void GetShuffledDeck_is_deterministic_for_same_master_seed_and_viewer() + { + var a = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck()))); + var b = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck()))); + Assert.That(a, Is.EqualTo(b)); + } + + [Test] + public void GetShuffledDeck_differs_across_master_seeds() + { + var a = new BattleSessionState(masterSeed: 1).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck()))); + var b = new BattleSessionState(masterSeed: 2).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck()))); + Assert.That(a, Is.Not.EqualTo(b)); + } + + private static long[] DistinctDeck() => + Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToArray(); }