merge: per-battle master seed + node-side deck shuffle
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
41
SVSim.BattleNode/Lifecycle/BattleSeeds.cs
Normal file
41
SVSim.BattleNode/Lifecycle/BattleSeeds.cs
Normal 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 Fisher–Yates).</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
/// (Fisher–Yates). 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
46
SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs
Normal file
46
SVSim.UnitTests/BattleNode/Lifecycle/BattleSeedsTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user