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