merge: per-battle master seed + node-side deck shuffle

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 18:25:13 -04:00
13 changed files with 254 additions and 44 deletions

View File

@@ -4,24 +4,20 @@ namespace SVSim.BattleNode.Lifecycle;
/// Default frame constants templated from TK2 prod captures, shared by the /// Default frame constants templated from TK2 prod captures, shared by the
/// server-authored battle-frame builders. Every value here originated in a real prod /// server-authored battle-frame builders. Every value here originated in a real prod
/// frame in <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them /// frame in <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them
/// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable and gives /// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable. The shared effect
/// the seed a single source of truth instead of two duplicated literals. /// seed and the deck-shuffle/idxChangeSeed are now derived per-battle from a master seed (see
/// <see cref="BattleSeeds"/>) — only animation/UI constants remain here.
/// </summary> /// </summary>
internal static class BattleFrameDefaults 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 frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
// from real per-viewer state needs a TK2 rank/battle-point tracker. // from real per-viewer state needs a TK2 rank/battle-point tracker.
public const string PlayerRank = "10"; public const string PlayerRank = "10";
public const string PlayerBattlePoint = "6270"; public const string PlayerBattlePoint = "6270";
// From frame[8] (Ready). Provenance is "what prod sent"; the client // From frame[8] (Ready). Provenance is "what prod sent"; the client doesn't validate. This is
// doesn't validate, but echoing matches the capture protects against // an animation crank value (shared-RNG spin), NOT gameplay randomness — both clients crank it
// a regression on a future tightening. // identically and stay synced, so it stays a constant. See the spin-rng audit.
public const int ReadyIdxChangeSeed = 771_335_280;
public const int ReadySpin = 243; public const int ReadySpin = 243;
/// <summary> /// <summary>

View File

@@ -0,0 +1,41 @@
namespace SVSim.BattleNode.Lifecycle;
/// <summary>
/// Deterministic per-battle seed derivation. Given one random master seed (chosen once per battle
/// on <see cref="Sessions.Dispatch.BattleSessionState"/>), 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.
/// </summary>
internal static class BattleSeeds
{
/// <summary>Shared effect-RNG seed; identical for both sides (it seeds the synced stream).</summary>
public static int Stable(int master) => Derive(master, "stable");
/// <summary>Per-side Ready.idxChangeSeed (client XorShift for mid-battle card-into-deck).</summary>
public static int IdxChange(int master, long viewerId) => Derive(master, "idx", viewerId);
/// <summary>Per-side deck-shuffle RNG seed (node-side FisherYates).</summary>
public static int DeckShuffle(int master, long viewerId) => Derive(master, "deck", viewerId);
/// <summary>Derive a stable non-negative int from (master, tag, discriminator). Pure arithmetic
/// — reproducible across process runs and platforms.</summary>
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);
}
}

View File

@@ -23,7 +23,7 @@ public static class ServerBattleFrames
public static MsgEnvelope BuildMatched( public static MsgEnvelope BuildMatched(
MatchContext selfCtx, MatchContext oppoCtx, MatchContext selfCtx, MatchContext oppoCtx,
long selfViewerId, long oppoViewerId, long selfViewerId, long oppoViewerId,
string battleId, long seed) => string battleId, long seed, IReadOnlyList<long> selfDeckOrder) =>
EnvelopeForPush(NetworkBattleUri.Matched, EnvelopeForPush(NetworkBattleUri.Matched,
new MatchedBody( new MatchedBody(
SelfInfo: new MatchedSelfInfo( SelfInfo: new MatchedSelfInfo(
@@ -47,7 +47,7 @@ public static class ServerBattleFrames
OppoId: selfViewerId, OppoId: selfViewerId,
Seed: seed, Seed: seed,
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count), OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
SelfDeck: BuildPlayerDeck(selfCtx.SelfDeckCardIds)), SelfDeck: BuildPlayerDeck(selfDeckOrder)),
bid: battleId); bid: battleId);
public static MsgEnvelope BuildBattleStart( public static MsgEnvelope BuildBattleStart(
@@ -113,16 +113,17 @@ public static class ServerBattleFrames
/// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder /// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder
/// <see cref="InitialHand"/>.</summary> /// <see cref="InitialHand"/>.</summary>
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) => BuildReady(hand, InitialHand); public static MsgEnvelope BuildReady(IReadOnlyList<long> hand, int idxChangeSeed) =>
BuildReady(hand, InitialHand, idxChangeSeed);
/// <summary>Both hands known (the mulligan barrier supplies the opponent's /// <summary>Both hands known (the mulligan barrier supplies the opponent's
/// post-mulligan hand).</summary> /// post-mulligan hand).</summary>
public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand) => public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand, int idxChangeSeed) =>
EnvelopeForPush(NetworkBattleUri.Ready, EnvelopeForPush(NetworkBattleUri.Ready,
new ReadyBody( new ReadyBody(
Self: BuildPosIdxList(selfHand), Self: BuildPosIdxList(selfHand),
Oppo: BuildPosIdxList(oppoHand), Oppo: BuildPosIdxList(oppoHand),
IdxChangeSeed: BattleFrameDefaults.ReadyIdxChangeSeed, IdxChangeSeed: idxChangeSeed,
Spin: BattleFrameDefaults.ReadySpin)); Spin: BattleFrameDefaults.ReadySpin));
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand) private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)

View File

@@ -22,6 +22,10 @@ public sealed class BattleSession
private readonly BattleSessionState _state = new(); private readonly BattleSessionState _state = new();
/// <summary>The per-battle master seed (see <see cref="BattleSessionState.MasterSeed"/>).
/// Exposed for logging + future replay persistence.</summary>
public int MasterSeed => _state.MasterSeed;
public string BattleId { get; } public string BattleId { get; }
public BattleType Type { get; } public BattleType Type { get; }
public IBattleParticipant A { get; } public IBattleParticipant A { get; }
@@ -71,6 +75,8 @@ public sealed class BattleSession
B = b; B = b;
_log = log; _log = log;
_log.LogInformation("BattleSession {Bid}: master seed {Seed}", BattleId, _state.MasterSeed);
// Subscribe to both participants' emissions. // Subscribe to both participants' emissions.
A.FrameEmitted += OnFrameFromA; A.FrameEmitted += OnFrameFromA;
B.FrameEmitted += OnFrameFromB; B.FrameEmitted += OnFrameFromB;

View File

@@ -1,3 +1,4 @@
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions;
namespace SVSim.BattleNode.Sessions.Dispatch; namespace SVSim.BattleNode.Sessions.Dispatch;
@@ -9,6 +10,34 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
/// <see cref="IdxToCardId"/> map via <see cref="RecordToken"/>; a reveal-gate set is still future.</summary> /// <see cref="IdxToCardId"/> map via <see cref="RecordToken"/>; a reveal-gate set is still future.</summary>
internal sealed class BattleSessionState internal sealed class BattleSessionState
{ {
/// <summary>The one random value chosen per battle. Every per-battle RNG (shared effect seed,
/// each side's deck shuffle + idxChangeSeed) derives from it via <see cref="BattleSeeds"/>.
/// Logged at session start so a battle's randomness is reproducible (future replay).</summary>
public int MasterSeed { get; }
/// <param name="masterSeed">Test hook — production uses the random default.</param>
public BattleSessionState(int? masterSeed = null) =>
MasterSeed = masterSeed ?? Random.Shared.Next();
private readonly Dictionary<IBattleParticipant, IReadOnlyList<long>> _shuffledDecks = new();
/// <summary>This side's deck, shuffled deterministically from <see cref="MasterSeed"/>
/// (FisherYates). Cached per side. Both the wire selfDeck (Matched) and the reveal map
/// (<see cref="GetOrSeedDeckMap"/>) read this, so they share one shuffled order.</summary>
public IReadOnlyList<long> 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 BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new(); public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
@@ -17,14 +46,15 @@ internal sealed class BattleSessionState
/// from add ops via <see cref="RecordToken"/>).</summary> /// from add ops via <see cref="RecordToken"/>).</summary>
public Dictionary<IBattleParticipant, Dictionary<int, long>> IdxToCardId { get; } = new(); public Dictionary<IBattleParticipant, Dictionary<int, long>> IdxToCardId { get; } = new();
/// <summary>The sender's idx->cardId map, seeding it from its <see cref="MatchContext"/> on first /// <summary>The sender's idx->cardId map, seeding it from its <see cref="GetShuffledDeck"/> order on
/// use. <c>BuildPlayerDeck</c> assigns deck idx = position+1, so entry (i+1) -> cardIds[i].</summary> /// 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.</summary>
public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side) public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side)
{ {
if (!IdxToCardId.TryGetValue(side, out var map)) if (!IdxToCardId.TryGetValue(side, out var map))
{ {
map = new Dictionary<int, long>(); map = new Dictionary<int, long>();
var deck = side.Context.SelfDeckCardIds; var deck = GetShuffledDeck(side);
for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i]; for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
IdxToCardId[side] = map; IdxToCardId[side] = map;
} }

View File

@@ -25,7 +25,8 @@ internal sealed class InitBattleHandler : IFrameHandler
{ {
new(ctx.From, ServerBattleFrames.BuildMatched( new(ctx.From, ServerBattleFrames.BuildMatched(
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId, 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; ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded;
return r; return r;

View File

@@ -27,10 +27,11 @@ internal sealed class SwapHandler : IFrameHandler
foreach (var p in swappers) foreach (var p in swappers)
{ {
var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A; var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A;
var idxSeed = BattleSeeds.IdxChange(ctx.State.MasterSeed, p.ViewerId);
var ready = opponent is IHasHandshakePhase var ready = opponent is IHasHandshakePhase
&& ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand) && ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand)
? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand) ? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand, idxSeed)
: ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p]); : ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], idxSeed);
routes.Add(new DispatchRoute(p, ready, false)); routes.Add(new DispatchRoute(p, ready, false));
} }
} }

View File

@@ -98,14 +98,23 @@ public class BattleNodeFlowTests
var body = ((RawBody)matched.Body).Entries; var body = ((RawBody)matched.Body).Entries;
var selfDeck = (List<object?>)body["selfDeck"]!; var selfDeck = (List<object?>)body["selfDeck"]!;
Assert.That(selfDeck.Count, Is.EqualTo(30)); 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<long>(30);
var cardIds = new List<long>(30);
foreach (var e in selfDeck)
{ {
var entry = (Dictionary<string, object?>)selfDeck[i]!; var entry = (Dictionary<string, object?>)e!;
Assert.That((long)entry["idx"]!, Is.EqualTo(i + 1L), idxs.Add((long)entry["idx"]!);
$"slot {i}: idx should be 1-based position"); cardIds.Add((long)entry["cardId"]!);
Assert.That((long)entry["cardId"]!, Is.EqualTo(draftedDeck[i]),
$"slot {i}: cardId should match the drafted card");
} }
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, private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq,

View File

@@ -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));
}
}

View File

@@ -14,7 +14,7 @@ public class ServerBattleFramesTests
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 906243102, oppoViewerId: 847666884, 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)); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
@@ -26,7 +26,7 @@ public class ServerBattleFramesTests
[Test] [Test]
public void BuildMatched_ContainsThirtyCardSelfDeck() 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; var body = (MatchedBody)env.Body;
Assert.That(body.SelfDeck.Count, Is.EqualTo(30)); 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() 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 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; var body = (MatchedBody)env.Body;
for (int i = 0; i < 30; i++) for (int i = 0; i < 30; i++)
@@ -56,7 +56,7 @@ public class ServerBattleFramesTests
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1, 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; var body = (MatchedBody)env.Body;
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN")); Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
@@ -136,11 +136,11 @@ public class ServerBattleFramesTests
} }
[Test] [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; 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.Spin, Is.EqualTo(243));
Assert.That(body.Self[1].Idx, Is.EqualTo(4)); Assert.That(body.Self[1].Idx, Is.EqualTo(4));
} }
@@ -148,7 +148,7 @@ public class ServerBattleFramesTests
[Test] [Test]
public void BuildReady_two_arg_sets_oppo_to_supplied_hand() 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; var body = (ReadyBody)env.Body;
Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 })); Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 }));
@@ -159,7 +159,7 @@ public class ServerBattleFramesTests
[Test] [Test]
public void BuildReady_one_arg_defaults_oppo_to_InitialHand() 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; var body = (ReadyBody)env.Body;
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }), Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }),

View File

@@ -28,7 +28,8 @@ public class TypedBodyWireShapeTests
// with "Value cannot be null. Parameter name: source". The prod wire format // with "Value cannot be null. Parameter name: source". The prod wire format
// emits envelope keys (uri first) before body keys; we must too. // emits envelope keys (uri first) before body keys; we must too.
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 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 json = MsgEnvelope.ToJson(env);
var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal); var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal);
@@ -47,7 +48,7 @@ public class TypedBodyWireShapeTests
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107", selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
seed: BattleFrameDefaults.BattleSeed); seed: 17_548_138L, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
var node = JsonNode.Parse(json)!.AsObject(); var node = JsonNode.Parse(json)!.AsObject();
@@ -137,7 +138,7 @@ public class TypedBodyWireShapeTests
[Test] [Test]
public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin() 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 json = MsgEnvelope.ToJson(env);
var node = JsonNode.Parse(json)!.AsObject(); var node = JsonNode.Parse(json)!.AsObject();

View File

@@ -91,6 +91,41 @@ public class BattleSessionDispatchTests
"Both sides must see the same seed."); "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] [Test]
public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only() public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only()
{ {

View File

@@ -27,17 +27,21 @@ public class BattleSessionStateTests
FieldId: 0, IsOfficial: 0, BattleType: 11); FieldId: 0, IsOfficial: 0, BattleType: 11);
[Test] [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 p = new StubParticipant(1, Ctx(900L, 901L, 902L));
var shuffled = state.GetShuffledDeck(p);
var map = state.GetOrSeedDeckMap(p); var map = state.GetOrSeedDeckMap(p);
Assert.That(map[1], Is.EqualTo(900L)); Assert.That(map[1], Is.EqualTo(shuffled[0]));
Assert.That(map[2], Is.EqualTo(901L)); Assert.That(map[2], Is.EqualTo(shuffled[1]));
Assert.That(map[3], Is.EqualTo(902L)); Assert.That(map[3], Is.EqualTo(shuffled[2]));
Assert.That(map.ContainsKey(4), Is.False); Assert.That(map.ContainsKey(4), Is.False);
Assert.That(new[] { map[1], map[2], map[3] }, Is.EquivalentTo(new[] { 900L, 901L, 902L }));
} }
[Test] [Test]
@@ -47,4 +51,43 @@ public class BattleSessionStateTests
var p = new StubParticipant(1, Ctx(900L)); var p = new StubParticipant(1, Ctx(900L));
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p))); 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();
} }