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( 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

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

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