diff --git a/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs b/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs index ca937bf..88f8f05 100644 --- a/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs +++ b/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs @@ -4,24 +4,20 @@ namespace SVSim.BattleNode.Lifecycle; /// Default frame constants templated from TK2 prod captures, shared by the /// server-authored battle-frame builders. Every value here originated in a real prod /// frame in data_dumps/captures/battle-traffic_tk2_regular.ndjson; pulling them -/// out of makes the magic numerics navigable and gives -/// the seed a single source of truth instead of two duplicated literals. +/// out of makes the magic numerics navigable. The shared effect +/// seed and the deck-shuffle/idxChangeSeed are now derived per-battle from a master seed (see +/// ) — only animation/UI constants remain here. /// internal static class BattleFrameDefaults { - // Shared per the spec — selfInfo.seed and oppoInfo.seed always agree. - // From frame[2] (Matched). - public const long BattleSeed = 17_548_138L; - // From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these // from real per-viewer state needs a TK2 rank/battle-point tracker. public const string PlayerRank = "10"; public const string PlayerBattlePoint = "6270"; - // From frame[8] (Ready). Provenance is "what prod sent"; the client - // doesn't validate, but echoing matches the capture protects against - // a regression on a future tightening. - public const int ReadyIdxChangeSeed = 771_335_280; + // From frame[8] (Ready). Provenance is "what prod sent"; the client doesn't validate. This is + // an animation crank value (shared-RNG spin), NOT gameplay randomness — both clients crank it + // identically and stay synced, so it stays a constant. See the spin-rng audit. public const int ReadySpin = 243; /// diff --git a/SVSim.BattleNode/Lifecycle/BattleSeeds.cs b/SVSim.BattleNode/Lifecycle/BattleSeeds.cs new file mode 100644 index 0000000..0d67b0c --- /dev/null +++ b/SVSim.BattleNode/Lifecycle/BattleSeeds.cs @@ -0,0 +1,41 @@ +namespace SVSim.BattleNode.Lifecycle; + +/// +/// Deterministic per-battle seed derivation. Given one random master seed (chosen once per battle +/// on ), derives every RNG value the node hands +/// the clients: the shared effect seed (Matched.seed), each side's deck-shuffle RNG seed, and each +/// side's Ready.idxChangeSeed. +/// +/// IMPORTANT: uses a fixed splitmix64-style bit-mix, NOT System.HashCode / string.GetHashCode +/// (those are randomized per process). Stability across process runs is what makes "same master +/// seed reproduces the same battle" — the foundation of replay — actually hold. +/// +internal static class BattleSeeds +{ + /// Shared effect-RNG seed; identical for both sides (it seeds the synced stream). + public static int Stable(int master) => Derive(master, "stable"); + + /// Per-side Ready.idxChangeSeed (client XorShift for mid-battle card-into-deck). + public static int IdxChange(int master, long viewerId) => Derive(master, "idx", viewerId); + + /// Per-side deck-shuffle RNG seed (node-side Fisher–Yates). + public static int DeckShuffle(int master, long viewerId) => Derive(master, "deck", viewerId); + + /// Derive a stable non-negative int from (master, tag, discriminator). Pure arithmetic + /// — reproducible across process runs and platforms. + public static int Derive(int master, string tag, long disc = 0) + { + ulong h = Mix((uint)master); + foreach (char c in tag) h = Mix(h ^ c); + h = Mix(h ^ (ulong)disc); + return (int)(h & 0x7FFFFFFFUL); + } + + private static ulong Mix(ulong x) + { + x += 0x9E3779B97F4A7C15UL; + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL; + return x ^ (x >> 31); + } +} diff --git a/SVSim.BattleNode/Lifecycle/ServerBattleFrames.cs b/SVSim.BattleNode/Lifecycle/ServerBattleFrames.cs index fd8063e..e1e4718 100644 --- a/SVSim.BattleNode/Lifecycle/ServerBattleFrames.cs +++ b/SVSim.BattleNode/Lifecycle/ServerBattleFrames.cs @@ -23,7 +23,7 @@ public static class ServerBattleFrames public static MsgEnvelope BuildMatched( MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, long oppoViewerId, - string battleId, long seed) => + string battleId, long seed, IReadOnlyList selfDeckOrder) => EnvelopeForPush(NetworkBattleUri.Matched, new MatchedBody( SelfInfo: new MatchedSelfInfo( @@ -47,7 +47,7 @@ public static class ServerBattleFrames OppoId: selfViewerId, Seed: seed, OppoDeckCount: oppoCtx.SelfDeckCardIds.Count), - SelfDeck: BuildPlayerDeck(selfCtx.SelfDeckCardIds)), + SelfDeck: BuildPlayerDeck(selfDeckOrder)), bid: battleId); public static MsgEnvelope BuildBattleStart( @@ -113,16 +113,17 @@ public static class ServerBattleFrames /// Non-interactive opponent (Bot/AI): oppo is the placeholder /// . - public static MsgEnvelope BuildReady(IReadOnlyList hand) => BuildReady(hand, InitialHand); + public static MsgEnvelope BuildReady(IReadOnlyList hand, int idxChangeSeed) => + BuildReady(hand, InitialHand, idxChangeSeed); /// Both hands known (the mulligan barrier supplies the opponent's /// post-mulligan hand). - public static MsgEnvelope BuildReady(IReadOnlyList selfHand, IReadOnlyList oppoHand) => + public static MsgEnvelope BuildReady(IReadOnlyList selfHand, IReadOnlyList oppoHand, int idxChangeSeed) => EnvelopeForPush(NetworkBattleUri.Ready, new ReadyBody( Self: BuildPosIdxList(selfHand), Oppo: BuildPosIdxList(oppoHand), - IdxChangeSeed: BattleFrameDefaults.ReadyIdxChangeSeed, + IdxChangeSeed: idxChangeSeed, Spin: BattleFrameDefaults.ReadySpin)); private static IReadOnlyList BuildPosIdxList(IReadOnlyList hand) diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index e219374..46bbfcf 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -22,6 +22,10 @@ public sealed class BattleSession private readonly BattleSessionState _state = new(); + /// The per-battle master seed (see ). + /// Exposed for logging + future replay persistence. + public int MasterSeed => _state.MasterSeed; + public string BattleId { get; } public BattleType Type { get; } public IBattleParticipant A { get; } @@ -71,6 +75,8 @@ public sealed class BattleSession B = b; _log = log; + _log.LogInformation("BattleSession {Bid}: master seed {Seed}", BattleId, _state.MasterSeed); + // Subscribe to both participants' emissions. A.FrameEmitted += OnFrameFromA; B.FrameEmitted += OnFrameFromB; 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.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs index 053c2fa..ae40040 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs @@ -25,7 +25,8 @@ internal sealed class InitBattleHandler : IFrameHandler { new(ctx.From, ServerBattleFrames.BuildMatched( ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId, - ctx.BattleId, BattleFrameDefaults.BattleSeed), false), + ctx.BattleId, BattleSeeds.Stable(ctx.State.MasterSeed), + ctx.State.GetShuffledDeck(ctx.From)), false), }; ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded; return r; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs index b4837ce..989f96e 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs @@ -27,10 +27,11 @@ internal sealed class SwapHandler : IFrameHandler foreach (var p in swappers) { var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A; + var idxSeed = BattleSeeds.IdxChange(ctx.State.MasterSeed, p.ViewerId); var ready = opponent is IHasHandshakePhase && ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand) - ? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand) - : ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p]); + ? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand, idxSeed) + : ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], idxSeed); routes.Add(new DispatchRoute(p, ready, false)); } } diff --git a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs index a2f7500..96c21bc 100644 --- a/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/BattleNodeFlowTests.cs @@ -98,14 +98,23 @@ public class BattleNodeFlowTests var body = ((RawBody)matched.Body).Entries; var selfDeck = (List)body["selfDeck"]!; Assert.That(selfDeck.Count, Is.EqualTo(30)); - for (int i = 0; i < 30; i++) + + // The node shuffles each deck per-battle from the master seed (see BattleSeeds / + // BattleSessionState.GetShuffledDeck), so cardIds are no longer in drafted order. What must + // hold: idxs are the contiguous 1..30 positions, and the set of cardIds is exactly the + // drafted deck (a permutation — same multiset, reordered). + var idxs = new List(30); + var cardIds = new List(30); + foreach (var e in selfDeck) { - var entry = (Dictionary)selfDeck[i]!; - Assert.That((long)entry["idx"]!, Is.EqualTo(i + 1L), - $"slot {i}: idx should be 1-based position"); - Assert.That((long)entry["cardId"]!, Is.EqualTo(draftedDeck[i]), - $"slot {i}: cardId should match the drafted card"); + var entry = (Dictionary)e!; + idxs.Add((long)entry["idx"]!); + cardIds.Add((long)entry["cardId"]!); } + Assert.That(idxs, Is.EqualTo(Enumerable.Range(1, 30).Select(i => (long)i)), + "idxs are the contiguous 1-based positions 1..30"); + Assert.That(cardIds, Is.EquivalentTo(draftedDeck), + "selfDeck is a permutation of the drafted deck (shuffled, same multiset)"); } private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq, diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs new file mode 100644 index 0000000..7554240 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; +using SVSim.BattleNode.Lifecycle; + +namespace SVSim.UnitTests.BattleNode.Lifecycle; + +[TestFixture] +public class BattleSeedsTests +{ + // Golden values pin cross-run/cross-platform stability. They were computed from the exact + // splitmix64 mix specified in BattleSeeds. If these ever change, replay reproducibility broke — + // do NOT "update them to match"; find what changed the algorithm (e.g. someone slipped in + // GetHashCode, which is per-process randomized). + [Test] + public void Derive_golden_values_are_stable() + { + Assert.That(BattleSeeds.Stable(12345), Is.EqualTo(1577307848)); + Assert.That(BattleSeeds.IdxChange(12345, 906243102), Is.EqualTo(1638231407)); + Assert.That(BattleSeeds.DeckShuffle(12345, 906243102), Is.EqualTo(355953180)); + Assert.That(BattleSeeds.IdxChange(12345, 847666884), Is.EqualTo(518125159)); + Assert.That(BattleSeeds.Stable(99999), Is.EqualTo(323349150)); + } + + [Test] + public void Derive_is_deterministic_for_same_inputs() + { + Assert.That(BattleSeeds.Derive(7, "x", 42), Is.EqualTo(BattleSeeds.Derive(7, "x", 42))); + } + + [Test] + public void Derive_differs_across_tag_master_and_discriminator() + { + var baseline = BattleSeeds.Derive(7, "x", 42); + Assert.That(BattleSeeds.Derive(8, "x", 42), Is.Not.EqualTo(baseline), "different master"); + Assert.That(BattleSeeds.Derive(7, "y", 42), Is.Not.EqualTo(baseline), "different tag"); + Assert.That(BattleSeeds.Derive(7, "x", 43), Is.Not.EqualTo(baseline), "different disc"); + } + + [Test] + public void Derive_is_always_non_negative() + { + // System.Random tolerates any int, but a non-negative seed keeps parity with prod's + // positive seed values and avoids surprises. + Assert.That(BattleSeeds.Stable(int.MinValue), Is.GreaterThanOrEqualTo(0)); + Assert.That(BattleSeeds.Stable(-1), Is.GreaterThanOrEqualTo(0)); + } +} diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs index 5080a01..a8fe15e 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs @@ -14,7 +14,7 @@ public class ServerBattleFramesTests { var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, oppoViewerId: 847666884, - battleId: "b", seed: BattleFrameDefaults.BattleSeed); + battleId: "b", seed: 17_548_138L, selfDeckOrder: FixtureCtx().SelfDeckCardIds); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched)); var body = (MatchedBody)env.Body; @@ -26,7 +26,7 @@ public class ServerBattleFramesTests [Test] public void BuildMatched_ContainsThirtyCardSelfDeck() { - var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed); + var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", 17_548_138L, FixtureCtx().SelfDeckCardIds); var body = (MatchedBody)env.Body; Assert.That(body.SelfDeck.Count, Is.EqualTo(30)); } @@ -35,7 +35,7 @@ public class ServerBattleFramesTests public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids() { var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList(); - var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed); + var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", 17_548_138L, draftedDeck); var body = (MatchedBody)env.Body; for (int i = 0; i < 30; i++) @@ -56,7 +56,7 @@ public class ServerBattleFramesTests EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1, }; - var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed); + var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", 17_548_138L, ctx.SelfDeckCardIds); var body = (MatchedBody)env.Body; Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN")); @@ -136,11 +136,11 @@ public class ServerBattleFramesTests } [Test] - public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand() + public void BuildReady_IncludesGivenIdxChangeSeedAndSpin_AndUsesGivenHand() { - var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }); + var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000); var body = (ReadyBody)env.Body; - Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280)); + Assert.That(body.IdxChangeSeed, Is.EqualTo(555_000)); Assert.That(body.Spin, Is.EqualTo(243)); Assert.That(body.Self[1].Idx, Is.EqualTo(4)); } @@ -148,7 +148,7 @@ public class ServerBattleFramesTests [Test] public void BuildReady_two_arg_sets_oppo_to_supplied_hand() { - var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 }); + var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 }, idxChangeSeed: 555_000); var body = (ReadyBody)env.Body; Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 })); @@ -159,7 +159,7 @@ public class ServerBattleFramesTests [Test] public void BuildReady_one_arg_defaults_oppo_to_InitialHand() { - var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }); + var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000); var body = (ReadyBody)env.Body; Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }), diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs index c7d48cc..436ffe6 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs @@ -28,7 +28,8 @@ public class TypedBodyWireShapeTests // with "Value cannot be null. Parameter name: source". The prod wire format // emits envelope keys (uri first) before body keys; we must too. var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), - selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: BattleFrameDefaults.BattleSeed); + selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: 17_548_138L, + selfDeckOrder: FixtureCtx().SelfDeckCardIds); var json = MsgEnvelope.ToJson(env); var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal); @@ -47,7 +48,7 @@ public class TypedBodyWireShapeTests { var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107", - seed: BattleFrameDefaults.BattleSeed); + seed: 17_548_138L, selfDeckOrder: FixtureCtx().SelfDeckCardIds); var json = MsgEnvelope.ToJson(env); var node = JsonNode.Parse(json)!.AsObject(); @@ -137,7 +138,7 @@ public class TypedBodyWireShapeTests [Test] public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin() { - var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }); + var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 771_335_280); var json = MsgEnvelope.ToJson(env); var node = JsonNode.Parse(json)!.AsObject(); diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 164792e..52d6721 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -91,6 +91,41 @@ public class BattleSessionDispatchTests "Both sides must see the same seed."); } + [Test] + public void Pvp_Matched_seed_derives_from_master_via_BattleSeeds_Stable() + { + var (s, a, _) = NewPvpSession(); + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); + var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); + + var body = (MatchedBody)routes[0].Frame.Body; + Assert.That(body.SelfInfo.Seed, Is.EqualTo((long)BattleSeeds.Stable(s.MasterSeed))); + Assert.That(body.OppoInfo.Seed, Is.EqualTo((long)BattleSeeds.Stable(s.MasterSeed))); + } + + [Test] + public void Pvp_Ready_idxChangeSeed_derives_from_master_and_recipient_viewer() + { + var (s, a, b) = NewPvpSession(); + // Both sides must complete the handshake before either can swap; then a swaps, then b's + // swap releases Ready to BOTH (mirrors Pvp_Swap_from_both_releases_Ready). + foreach (var p in new[] { a, b }) + { + s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork)); + s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle)); + s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded)); + } + s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // a swaps first + var bRoutes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Swap)); // b releases both Readys + + var readyToA = bRoutes.Single(r => ReferenceEquals(r.Target, a) && r.Frame.Uri == NetworkBattleUri.Ready); + var readyToB = bRoutes.Single(r => ReferenceEquals(r.Target, b) && r.Frame.Uri == NetworkBattleUri.Ready); + Assert.That(((ReadyBody)readyToA.Frame.Body).IdxChangeSeed, + Is.EqualTo(BattleSeeds.IdxChange(s.MasterSeed, a.ViewerId))); + Assert.That(((ReadyBody)readyToB.Frame.Body).IdxChangeSeed, + Is.EqualTo(BattleSeeds.IdxChange(s.MasterSeed, b.ViewerId))); + } + [Test] public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only() { 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(); }