feat(battle-node): derive Matched.seed + Ready.idxChangeSeed from master seed

InitBattle now emits Stable(master) as the shared effect seed and the master-
shuffled deck as selfDeck; Swap emits each recipient's per-side IdxChange seed.
BattleSession exposes + logs the master seed per battle for future replay.
Updated lifecycle/dispatch/integration tests (deck assertions now permutation-
based since selfDeck is shuffled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 18:20:51 -04:00
parent 6f7fcfe28e
commit 3f5d97cb2f
8 changed files with 80 additions and 26 deletions

View File

@@ -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<long> 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
/// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder
/// <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
/// 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,
new ReadyBody(
Self: BuildPosIdxList(selfHand),
Oppo: BuildPosIdxList(oppoHand),
IdxChangeSeed: BattleFrameDefaults.ReadyIdxChangeSeed,
IdxChangeSeed: idxChangeSeed,
Spin: BattleFrameDefaults.ReadySpin));
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)

View File

@@ -22,6 +22,10 @@ public sealed class BattleSession
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 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;

View File

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

View File

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

View File

@@ -98,14 +98,23 @@ public class BattleNodeFlowTests
var body = ((RawBody)matched.Body).Entries;
var selfDeck = (List<object?>)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<long>(30);
var cardIds = new List<long>(30);
foreach (var e in selfDeck)
{
var entry = (Dictionary<string, object?>)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<string, object?>)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,

View File

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

View File

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

View File

@@ -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()
{