fix(battlenode): shadow engine tracks live PvP wire-truth (full battle, multiple bid regressions)
Six distinct fixes accumulated over live-test iterations against four bids (654473755566, 806245601092, 283192092460, 131549100204, 799755786270) — together they take the shadow engine from "throws on the first non-mulligan play" to "survives a full PvP battle, only weird-edge-case Unity touches still left to whack". 1. Engine StableRandom seed aligned with clients' Matched.seed (BattleSession.EnsureEngineSetup, NodeNativeBattleHarness.Create). Clients seed _stableRandom with BattleSeeds.Stable(masterSeed) (the value the node ships in Matched.seed); we were passing the RAW masterSeed to engine.Setup, so every StableRandom call diverged from call #1 onward — every turn-1+ draw picked a different deck position than the clients. Verified Stable(1184631275)=1543475792 matches the wire on bid 654473755566. 2. SeedDeck advances cardTotalNum to deck.Count+1 + pins BattleStartDeckCardList. Mirrors SBattleLoad.InitPlayer's tail (SBattleLoad.cs:1292). Without it, skill-generated tokens auto-assigned Index 0,1,... and COLLIDED with deck-loaded indices 1..40 — silent until something addressed the deck card with the colliding Index (Hoverboarder at deck idx 1 + a token at engine Index 1 made GetBattleCardIdx's SingleOrDefault throw on bid 806245601092). 3. BattleCardView.GameObject lazily non-null in the shim (ViewUiTouchStubs.cs). The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes null cardGameObject; Skill_metamorphose.cs:147 in the in-play branch then NRE'd on `metamorphosedCard.BattleCardView.GameObject.transform.rotation = identity`, a purely cosmetic touch with no game-state implication. Bid 283192092460: Petrification on a board follower. 4. TranslateChoiceKeyAction unwraps wrapped selectCard on shadow ingest (SessionBattleEngine.cs, sibling to TranslateTargetOwners). Live sender-send wires Choice plays as selectCard:{cardId:[...], open:0}; engine's ConvertToListInt does `value as List<object>` — a Dict casts to null and foreach NREs. The receiver's swallow-all catch (NetworkBattleReceiver.cs:1255) logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and returns false, but Receive calls ReceivedMessage with checkBreakData:false so the false isn't propagated. The play continues with choiceIdList=[], the chosen branch never resolves, the played card stays in hand; a later targeted play (A's bounce on B's "board" idx 20) then can't find the target → NRE on null in ActionProcessor.PlayCard:407. Bid 131549100204: B's Resonance + A's bounce. Opponent-relay path is unaffected — node strips selectCard from broadcasts. 5. HeadlessHandViewStub overrides HandUnfocus/HandFocus/FocusRearrangeHandHand to return NullVfx. CreateHandControl returns null in headless; the base methods unconditionally deref `_handControl.SetHandState(...)`. A follower with a when_spell_play Heal trigger fired on its leader for amount 0 — even a 0-heal drives ApplyHealing → CreatePullHandInVfx → HandUnfocus → NRE. Bid 799755786270: two consecutive spell plays both crashed this stack. Added InternalsVisibleTo("SVSim.BattleEngine.Tests") so the shim-level regression tests can pin the no-op contracts directly. Plus the previous-session fixes carried in this same uncommitted state (see docs/superpowers/plans/2026-06-07-shadow-engine-desync-handoff.md): - doesPlayerGoFirst:true + mgr.IsFirst:true (turn-1 draw count correct per seat) - RecoveryOperationCollection.PlayHandCardOperation routes all type:30 through PlaySkillSelectHandCardOperation (skips the two-phase user-select guard that aborts targeted spells in recovery) - ShadowFeed + ToRawBody: server-generated typed bodies (DealBody, etc.) converted to RawBody before engine.Receive (`env.Body as RawBody` returned null for typed bodies) - Ready idxChangeSeed seeds A's XorShift via the receiver; B's seed is injected via SeedOppoIdxChange (BattleSeeds.IdxChange + viewerId) - ReadySpin defaulted to 0 (was 243) — non-zero double-cranks the shadow which ingests BOTH sides' Ready frames on one stream Test counts: SVSim.UnitTests 1054/1054, SVSim.BattleEngine.Tests 34/34. Open: known-residual Unity touches are individual whack-a-mole now (per-card skill edge cases), not the structural divergences fixed here. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
SVSim.BattleEngine.Tests/BattleCardViewShimTests.cs
Normal file
56
SVSim.BattleEngine.Tests/BattleCardViewShimTests.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using Wizard.Battle.View;
|
||||
|
||||
namespace SVSim.BattleEngine.Tests
|
||||
{
|
||||
// Regression for the in-play metamorphose NRE diagnosed 2026-06-07 (bid 283192092460).
|
||||
//
|
||||
// The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes null for the
|
||||
// cardGameObject, which left BattleCardView.GameObject null. Skill_metamorphose.cs:147 in the
|
||||
// IsInplay branch then NRE'd on the unguarded
|
||||
// metamorphosedCard.BattleCardView.GameObject.transform.rotation = Quaternion.identity
|
||||
// — a purely cosmetic transform reset that has no corresponding state mutation, but tripped over
|
||||
// null-GameObject before the surrounding mutations (ReplaceInPlay, SetUpInplay,
|
||||
// FlagCardAsDestroyedBySkill, RemoveFromInPlay) could complete.
|
||||
//
|
||||
// Fix: ViewUiTouchStubs.cs's BattleCardView.GameObject is now lazily non-null (matches the
|
||||
// existing Component.gameObject pattern at UnityShim.cs:94). The shim materializes a no-op
|
||||
// GameObject on first read; the cosmetic touch resolves to a no-op assignment instead of NRE.
|
||||
[TestFixture]
|
||||
public class BattleCardViewShimTests
|
||||
{
|
||||
[Test]
|
||||
public void GameObject_is_lazily_non_null_so_unguarded_recovery_touches_no_op()
|
||||
{
|
||||
var view = new BattleCardView();
|
||||
|
||||
Assert.That(view.GameObject, Is.Not.Null,
|
||||
"BattleCardView.GameObject must be lazily non-null in the shim so unguarded " +
|
||||
"Unity touches on the IsRecovery card-create path (which passes null cardGameObject) " +
|
||||
"resolve to no-ops instead of NRE-ing.");
|
||||
|
||||
Assert.That(view.GameObject.transform, Is.Not.Null,
|
||||
"GameObject.transform must follow the shim's lazy non-null Component pattern (UnityShim.cs:94).");
|
||||
|
||||
Assert.DoesNotThrow(() => view.GameObject.transform.rotation = Quaternion.identity,
|
||||
"Skill_metamorphose.cs:147's cosmetic transform.rotation reset on the in-play branch must " +
|
||||
"not throw in the headless IsRecovery path (live bid 283192092460: A's Petrification " +
|
||||
"on B's in-play card).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GameObject_is_stable_across_reads_so_a_set_followed_by_read_returns_the_same_instance()
|
||||
{
|
||||
// Lazy materialization caches the GameObject on first read, so subsequent reads return
|
||||
// the same instance — required for any code path that reads .GameObject, mutates it,
|
||||
// and reads again (e.g. follower position/rotation/scale set in sequence).
|
||||
var view = new BattleCardView();
|
||||
var first = view.GameObject;
|
||||
var second = view.GameObject;
|
||||
Assert.That(second, Is.SameAs(first),
|
||||
"lazy GameObject must cache; otherwise the second read returns a fresh instance " +
|
||||
"and any mutation on the first read is lost.");
|
||||
}
|
||||
}
|
||||
}
|
||||
109
SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl1.ndjson
Normal file
109
SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl1.ndjson
Normal file
@@ -0,0 +1,109 @@
|
||||
{"ts":"2026-06-07T12:05:10.0824442Z","direction":"receive","uri":null,"body":{"uri":"InitNetwork","viewerId":999999999,"uuid":"node-stub","try":0,"cat":99,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:10.1134456Z","direction":"receive","uri":null,"body":{"uri":"Matched","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"bid":"907324319325","playSeq":1,"selfInfo":{"country_code":"KOR","userName":"SVSim1","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":6,"seed":742186477},"oppoInfo":{"country_code":"KOR","userName":"SVSim2","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":7,"seed":742186477,"oppoDeckCount":40},"selfDeck":[{"idx":1,"cardId":101324040},{"idx":2,"cardId":101321070},{"idx":3,"cardId":101321040},{"idx":4,"cardId":101324050},{"idx":5,"cardId":101334030},{"idx":6,"cardId":102334020},{"idx":7,"cardId":101024010},{"idx":8,"cardId":102331010},{"idx":9,"cardId":101324040},{"idx":10,"cardId":101314020},{"idx":11,"cardId":127011010},{"idx":12,"cardId":100314020},{"idx":13,"cardId":101024010},{"idx":14,"cardId":701341011},{"idx":15,"cardId":101311010},{"idx":16,"cardId":101311050},{"idx":17,"cardId":102324040},{"idx":18,"cardId":101341010},{"idx":19,"cardId":127011010},{"idx":20,"cardId":101311010},{"idx":21,"cardId":101314020},{"idx":22,"cardId":100321010},{"idx":23,"cardId":101321070},{"idx":24,"cardId":100314030},{"idx":25,"cardId":101314020},{"idx":26,"cardId":101311050},{"idx":27,"cardId":101024010},{"idx":28,"cardId":100314010},{"idx":29,"cardId":127011010},{"idx":30,"cardId":100314040},{"idx":31,"cardId":100321010},{"idx":32,"cardId":101334020},{"idx":33,"cardId":100314030},{"idx":34,"cardId":100314040},{"idx":35,"cardId":101321040},{"idx":36,"cardId":102324040},{"idx":37,"cardId":100314020},{"idx":38,"cardId":101334040},{"idx":39,"cardId":100314010},{"idx":40,"cardId":101324050}],"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:13.3684415Z","direction":"receive","uri":null,"body":{"uri":"BattleStart","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":2,"turnState":0,"battleType":11,"selfInfo":{"rank":"10","battlePoint":"6270","classId":"3","charaId":"3","cardMasterName":"card_master_node_10015"},"oppoInfo":{"rank":"1","isMasterRank":"0","battlePoint":0,"masterPoint":"0","classId":"1","charaId":"1","cardMasterName":"card_master_node_10015"},"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:13.3699431Z","direction":"receive","uri":null,"body":{"uri":"Deal","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":3,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:34.8570706Z","direction":"send","uri":"Swap","body":{"idxList":[2,3]}}
|
||||
{"ts":"2026-06-07T12:05:34.8895711Z","direction":"receive","uri":null,"body":{"uri":"Swap","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":4,"self":[{"pos":0,"idx":1},{"pos":1,"idx":4},{"pos":2,"idx":5}],"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:34.8905684Z","direction":"receive","uri":null,"body":{"uri":"Ready","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":5,"self":[{"pos":0,"idx":1},{"pos":1,"idx":4},{"pos":2,"idx":5}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"idxChangeSeed":1430655717,"spin":243,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:36.6990699Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[8],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":0}}
|
||||
{"ts":"2026-06-07T12:05:42.2485694Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,8],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}}]}}
|
||||
{"ts":"2026-06-07T12:05:42.7450678Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"141","key2":"56","key3":"0","key4":"143","key5":"14","key6":"0"},"type":0,"actionSeq":2,"cemetery":[0,0]}}
|
||||
{"ts":"2026-06-07T12:05:42.8775704Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":8,"playSeq":6,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:42.9050694Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[10,16],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"avarice":1}}]}}
|
||||
{"ts":"2026-06-07T12:05:46.4670675Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":9,"playSeq":7}}
|
||||
{"ts":"2026-06-07T12:05:46.4855683Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,2,3,10,16],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}},{"trigger":{"isSelf":0,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:05:46.9690709Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":10,"playSeq":8,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:46.9860711Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"141","key2":"56","key3":"0","key4":"142","key5":"134","key6":"0"}}}
|
||||
{"ts":"2026-06-07T12:05:47.0020697Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":10,"playSeq":9,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:47.4990684Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[29],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":4}}
|
||||
{"ts":"2026-06-07T12:05:54.6460692Z","direction":"send","uri":"PlayActions","body":{"playIdx":8,"orderList":[{"move":{"idx":[8],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:05:55.7140680Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:05:56.2210693Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"143","key2":"140","key3":"102331036","key4":"142","key5":"134","key6":"0"},"type":0,"actionSeq":7,"cemetery":[0,0]}}
|
||||
{"ts":"2026-06-07T12:05:57.0875698Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":15,"playSeq":10,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:57.1090694Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[15],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
|
||||
{"ts":"2026-06-07T12:06:12.6924224Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":16,"playSeq":11,"playIdx":1,"type":30,"knownList":[{"idx":1,"cardId":102131030,"to":20,"spellboost":0,"attachTarget":"","cost":2,"clan":1,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:06:12.9394251Z","direction":"send","uri":"Echo","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:16.5024225Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":17,"playSeq":12}}
|
||||
{"ts":"2026-06-07T12:06:16.5194264Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,10,16,15],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:06:16.9874227Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":18,"playSeq":13,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:17.0039250Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"143","key2":"140","key3":"102331036","key4":"144","key5":"177","key6":"102131049"}}}
|
||||
{"ts":"2026-06-07T12:06:17.0209229Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":18,"playSeq":14,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:17.0494250Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[3],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":10}}
|
||||
{"ts":"2026-06-07T12:06:28.8094232Z","direction":"send","uri":"PlayActions","body":{"playIdx":3,"orderList":[{"move":{"idx":[3],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:29.8539237Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:06:30.3519249Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"145","key2":"140","key3":"203652104","key4":"144","key5":"177","key6":"102131049"},"type":0,"actionSeq":13,"cemetery":[0,0]}}
|
||||
{"ts":"2026-06-07T12:06:31.2029243Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":23,"playSeq":15,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:31.2239242Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[24],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:06:36.0499227Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":25,"playSeq":16,"playIdx":1,"type":10,"knownList":[{"idx":1,"cardId":102131030,"to":30,"spellboost":0,"attachTarget":"","cost":2,"clan":1,"tribe":"0"}],"oppoTargetList":[{"targetIdx":8,"isSelf":0}]}}
|
||||
{"ts":"2026-06-07T12:06:36.0879224Z","direction":"send","uri":"Echo","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":0,"from":20,"to":30}},{"move":{"idx":[8],"isSelf":1,"from":20,"to":30}}],"type":10}}
|
||||
{"ts":"2026-06-07T12:06:36.7079231Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":26,"playSeq":17}}
|
||||
{"ts":"2026-06-07T12:06:37.1924235Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":27,"playSeq":18,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:38.0604227Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,10,16,15,24],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}}]}}
|
||||
{"ts":"2026-06-07T12:06:38.1769227Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"147","key2":"140","key3":"101321058","key4":"148","key5":"321","key6":"0"}}}
|
||||
{"ts":"2026-06-07T12:06:38.1919253Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":26,"playSeq":19,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:38.2194225Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":2}},{"move":{"idx":[19],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":16}}
|
||||
{"ts":"2026-06-07T12:06:46.5499241Z","direction":"send","uri":"PlayActions","body":{"playIdx":29,"keyAction":[{"type":1,"cardId":127011010,"selectCard":{"cardId":[121011010],"open":0}}],"orderList":[{"move":{"idx":[29],"isSelf":1,"from":10,"to":20}},{"add":{"idx":[41],"isSelf":1,"card":{"cardId":121011010},"isChoice":"1"}},{"move":{"idx":[41],"isSelf":1,"from":50,"to":10}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:50.3119230Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,19,41],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:06:50.8109234Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"149","key2":"305","key3":"228332150","key4":"148","key5":"321","key6":"0"},"type":0,"actionSeq":19,"cemetery":[1,1]}}
|
||||
{"ts":"2026-06-07T12:06:50.9109252Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":32,"playSeq":20,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:50.9319252Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[11],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
|
||||
{"ts":"2026-06-07T12:06:55.3344248Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":33,"playSeq":21,"playIdx":10,"type":30,"knownList":[{"idx":10,"cardId":101121080,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:06:55.5239284Z","direction":"send","uri":"Echo","body":{"playIdx":10,"orderList":[{"move":{"idx":[10],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:56.0979233Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":34,"playSeq":22}}
|
||||
{"ts":"2026-06-07T12:06:56.5964232Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":35,"playSeq":23,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:57.4474248Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}}]}}
|
||||
{"ts":"2026-06-07T12:06:57.5634280Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"149","key2":"305","key3":"228332150","key4":"150","key5":"302","key6":"101121116"}}}
|
||||
{"ts":"2026-06-07T12:06:57.5794253Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":36,"playSeq":24,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:57.6139259Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[39],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":22}}
|
||||
{"ts":"2026-06-07T12:07:02.6699249Z","direction":"send","uri":"PlayActions","body":{"playIdx":39,"orderList":[{"move":{"idx":[39],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[1,4,5,19,41],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"7"}},{"move":{"idx":[17],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}},{"trigger":{"isSelf":1,"avarice":1}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:10.2104225Z","direction":"send","uri":"PlayActions","body":{"playIdx":41,"orderList":[{"move":{"idx":[41],"isSelf":1,"from":10,"to":20}},{"move":{"idx":[6],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:17.7444250Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,19,17,6],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}},{"trigger":{"isSelf":1,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:18.2599231Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"147","key2":"221","key3":"349343345","key4":"150","key5":"302","key6":"101121116"},"type":0,"actionSeq":26,"cemetery":[2,1]}}
|
||||
{"ts":"2026-06-07T12:07:18.3594228Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":41,"playSeq":25,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:18.3874231Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[6],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:22.0834250Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":42,"playSeq":26,"playIdx":6,"type":30,"knownList":[{"idx":6,"cardId":113011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"uList":[{"idxList":[34],"from":0,"to":10,"isSelf":1,"skill":"6|19|0"}]}}
|
||||
{"ts":"2026-06-07T12:07:22.2814232Z","direction":"send","uri":"Echo","body":{"playIdx":6,"orderList":[{"move":{"idx":[6],"isSelf":0,"from":10,"to":20}},{"target":{"isSelf":0,"group":["g1"],"conditions":[{"state":0,"tribe":"eq7"}],"rand":[[0.739030951046865]]}},{"move":{"idx":"g1","isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}},{"trigger":{"isSelf":0,"avarice":1}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:25.6384231Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":43,"playSeq":27}}
|
||||
{"ts":"2026-06-07T12:07:25.6554259Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,15,24,11,34],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}},{"trigger":{"isSelf":0,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:26.1384241Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":44,"playSeq":28,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:26.1544243Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"147","key2":"221","key3":"349343345","key4":"149","key5":"540","key6":"214132162"}}}
|
||||
{"ts":"2026-06-07T12:07:26.1709251Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":45,"playSeq":29,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:26.2184224Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[32],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":29}}
|
||||
{"ts":"2026-06-07T12:07:34.2019228Z","direction":"send","uri":"PlayActions","body":{"playIdx":1,"targetList":[{"targetIdx":6,"isSelf":1,"selectSkillIndex":[1],"skillIndex":[1]}],"orderList":[{"move":{"idx":[1],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[4,5,19,17,6,32],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"20"}},{"alter":{"idx":[6],"isSelf":1,"type":"add","spellboost":"a2","attachTarget":"21"}},{"move":{"idx":[23],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}},{"trigger":{"isSelf":1,"avarice":1}}],"type":31}}
|
||||
{"ts":"2026-06-07T12:07:41.2306722Z","direction":"send","uri":"PlayActions","body":{"playIdx":17,"orderList":[{"move":{"idx":[17],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[4,5,19,6,32,23],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"23"}},{"add":{"idx":[42],"isSelf":1,"card":{"cardId":900311050}}},{"move":{"idx":[42],"isSelf":1,"from":50,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:46.6846799Z","direction":"send","uri":"PlayActions","body":{"playIdx":41,"targetList":[{"targetIdx":10,"isSelf":0}],"type":10}}
|
||||
{"ts":"2026-06-07T12:07:48.2356829Z","direction":"send","uri":"PlayActions","body":{"playIdx":29,"targetList":[{"targetIdx":10,"isSelf":0}],"orderList":[{"move":{"idx":[29],"isSelf":1,"from":20,"to":30}},{"move":{"idx":[10],"isSelf":0,"from":20,"to":30}}],"type":10}}
|
||||
{"ts":"2026-06-07T12:07:49.9904200Z","direction":"send","uri":"PlayActions","body":{"playIdx":3,"targetList":[{"targetIdx":6,"isSelf":0}],"orderList":[{"move":{"idx":[3],"isSelf":1,"from":20,"to":30}},{"move":{"idx":[6],"isSelf":0,"from":20,"to":30}}],"type":10}}
|
||||
{"ts":"2026-06-07T12:07:51.8734061Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[4,5,19,6,32,23],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":5}},{"trigger":{"isSelf":1,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:52.3726572Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"154","key2":"393","key3":"1021322270","key4":"153","key5":"540","key6":"0"},"type":0,"actionSeq":36,"cemetery":[6,3]}}
|
||||
{"ts":"2026-06-07T12:07:52.4729369Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":53,"playSeq":30,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:52.4946960Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":6}},{"move":{"idx":[18],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:57.1776003Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":54,"playSeq":31,"playIdx":34,"type":30,"knownList":[{"idx":34,"cardId":113011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"uList":[{"idxList":[5],"from":0,"to":10,"isSelf":1,"skill":"34|28|0"}]}}
|
||||
{"ts":"2026-06-07T12:07:57.2503917Z","direction":"send","uri":"Echo","body":{"playIdx":34,"orderList":[{"move":{"idx":[34],"isSelf":0,"from":10,"to":20}},{"target":{"isSelf":0,"group":["g1"],"conditions":[{"state":0,"tribe":"eq7"}],"rand":[[0.668529128501438]]}},{"move":{"idx":"g1","isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}},{"trigger":{"isSelf":0,"avarice":1}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:58.2623261Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":55,"playSeq":32,"playIdx":18,"type":30,"knownList":[{"idx":18,"cardId":100111010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:08:00.2645722Z","direction":"send","uri":"Echo","body":{"playIdx":18,"orderList":[{"move":{"idx":[18],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:02.7695981Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":56,"playSeq":33,"playIdx":5,"type":30,"knownList":[{"idx":5,"cardId":113011010,"to":20,"spellboost":0,"attachTarget":"","cost":2,"clan":0,"tribe":"7"}]}}
|
||||
{"ts":"2026-06-07T12:08:02.8451199Z","direction":"send","uri":"Echo","body":{"playIdx":5,"orderList":[{"move":{"idx":[5],"isSelf":0,"from":10,"to":20}},{"scan":{"idx":[4,7,8,9,12,13,14,17,19,20,21,22,23,25,26,27,28,29,30,31,32,33,35,36,37,38,39,40],"conditions":[{"tribe":"7"}]}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:05.7442862Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":57,"playSeq":34}}
|
||||
{"ts":"2026-06-07T12:08:05.7667846Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":1}},{"move":{"idx":[42],"isSelf":1,"from":20,"to":30,"hasGuard":[42]}},{"trigger":{"isSelf":0,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:08:06.2448192Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":58,"playSeq":35,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:06.2608181Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"156","key2":"393","key3":"121011060","key4":"152","key5":"302","key6":"326133205"}}}
|
||||
{"ts":"2026-06-07T12:08:06.2778185Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":64,"playSeq":36,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:06.3228189Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[38],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":41}}
|
||||
{"ts":"2026-06-07T12:08:17.8343721Z","direction":"send","uri":"PlayActions","body":{"playIdx":19,"keyAction":[{"type":1,"cardId":127011010,"selectCard":{"cardId":[120011010],"open":0}}],"orderList":[{"move":{"idx":[19],"isSelf":1,"from":10,"to":20}},{"add":{"idx":[43],"isSelf":1,"card":{"cardId":120011010},"isChoice":"1"}},{"move":{"idx":[43],"isSelf":1,"from":50,"to":10}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:21.3291075Z","direction":"send","uri":"PlayActions","body":{"playIdx":4,"targetList":[{"targetIdx":5,"isSelf":0,"selectSkillIndex":[1]}],"orderList":[{"move":{"idx":[4],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[5,6,32,23,38,43],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"33"}},{"metamorphose":{"idx":[5],"isSelf":0,"after":{"cardId":900311020}}}],"type":31}}
|
||||
{"ts":"2026-06-07T12:08:25.9578557Z","direction":"send","uri":"PlayActions","body":{"playIdx":5,"targetList":[{"targetIdx":34,"isSelf":0,"selectSkillIndex":[1]}],"orderList":[{"move":{"idx":[5],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[6,32,23,38,43],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"36"}},{"move":{"idx":[34],"isSelf":0,"from":20,"to":30}},{"add":{"idx":[44],"isSelf":1,"card":{"cardId":900334010}}},{"move":{"idx":[44],"isSelf":1,"from":50,"to":10}}],"type":31}}
|
||||
{"ts":"2026-06-07T12:08:29.5860517Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[6,32,23,38,43,44],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}}]}}
|
||||
{"ts":"2026-06-07T12:08:30.0854894Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"162","key2":"770","key3":"248022140","key4":"154","key5":"302","key6":"1000422107"},"type":0,"actionSeq":46,"cemetery":[9,4]}}
|
||||
{"ts":"2026-06-07T12:08:30.1853353Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":65,"playSeq":37,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:30.2078357Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[35],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:08:37.7255447Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":66,"playSeq":38,"playIdx":15,"type":30,"knownList":[{"idx":15,"cardId":101121110,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:08:37.9275599Z","direction":"send","uri":"Echo","body":{"playIdx":15,"orderList":[{"move":{"idx":[15],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:38.5997627Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":67,"playSeq":39}}
|
||||
{"ts":"2026-06-07T12:08:39.0994174Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":68,"playSeq":40,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:39.8688009Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,24,11,35],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":5}}]}}
|
||||
{"ts":"2026-06-07T12:08:39.9995393Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"162","key2":"770","key3":"248022140","key4":"156","key5":"417","key6":"1101543355"}}}
|
||||
{"ts":"2026-06-07T12:08:40.0160656Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":80,"playSeq":41,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:40.0427529Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[20],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":49}}
|
||||
{"ts":"2026-06-07T12:08:44.0590977Z","direction":"send","uri":"PlayActions","body":{"playIdx":20,"orderList":[{"move":{"idx":[20],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:49.2798814Z","direction":"send","uri":"PlayActions","body":{"playIdx":23,"orderList":[{"move":{"idx":[23],"isSelf":1,"from":10,"to":20}},{"playerParam":{"isSelf":1,"buffUnit":1}}],"type":30}}
|
||||
118
SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl2.ndjson
Normal file
118
SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl2.ndjson
Normal file
@@ -0,0 +1,118 @@
|
||||
{"ts":"2026-06-07T12:05:10.0764449Z","direction":"receive","uri":null,"body":{"uri":"InitNetwork","viewerId":999999999,"uuid":"node-stub","try":0,"cat":99,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:10.1264431Z","direction":"receive","uri":null,"body":{"uri":"Matched","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"bid":"907324319325","playSeq":1,"selfInfo":{"country_code":"KOR","userName":"SVSim2","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":7,"seed":742186477},"oppoInfo":{"country_code":"KOR","userName":"SVSim1","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":6,"seed":742186477,"oppoDeckCount":40},"selfDeck":[{"idx":1,"cardId":102131030},{"idx":2,"cardId":101121080},{"idx":3,"cardId":101131050},{"idx":4,"cardId":101114010},{"idx":5,"cardId":113011010},{"idx":6,"cardId":113011010},{"idx":7,"cardId":101121020},{"idx":8,"cardId":101121010},{"idx":9,"cardId":102141010},{"idx":10,"cardId":101121080},{"idx":11,"cardId":101114010},{"idx":12,"cardId":102111060},{"idx":13,"cardId":102131020},{"idx":14,"cardId":102131010},{"idx":15,"cardId":101121110},{"idx":16,"cardId":101121110},{"idx":17,"cardId":100111020},{"idx":18,"cardId":100111010},{"idx":19,"cardId":102121030},{"idx":20,"cardId":100111020},{"idx":21,"cardId":101121080},{"idx":22,"cardId":101121020},{"idx":23,"cardId":100111070},{"idx":24,"cardId":102111060},{"idx":25,"cardId":101131020},{"idx":26,"cardId":101114050},{"idx":27,"cardId":101114050},{"idx":28,"cardId":101121010},{"idx":29,"cardId":701141011},{"idx":30,"cardId":102121010},{"idx":31,"cardId":100111010},{"idx":32,"cardId":100114010},{"idx":33,"cardId":101114050},{"idx":34,"cardId":113011010},{"idx":35,"cardId":100114010},{"idx":36,"cardId":100111020},{"idx":37,"cardId":102121030},{"idx":38,"cardId":102121010},{"idx":39,"cardId":100114010},{"idx":40,"cardId":100111070}],"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:13.3624432Z","direction":"receive","uri":null,"body":{"uri":"BattleStart","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":2,"turnState":1,"battleType":11,"selfInfo":{"rank":"10","battlePoint":"6270","classId":"1","charaId":"1","cardMasterName":"card_master_node_10015"},"oppoInfo":{"rank":"1","isMasterRank":"0","battlePoint":0,"masterPoint":"0","classId":"3","charaId":"3","cardMasterName":"card_master_node_10015"},"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:13.3644442Z","direction":"receive","uri":null,"body":{"uri":"Deal","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":3,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:29.7550686Z","direction":"send","uri":"Swap","body":{"idxList":[]}}
|
||||
{"ts":"2026-06-07T12:05:29.7695695Z","direction":"receive","uri":null,"body":{"uri":"Swap","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":4,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:34.8895711Z","direction":"receive","uri":null,"body":{"uri":"Ready","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":5,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":4},{"pos":2,"idx":5}],"idxChangeSeed":661650374,"spin":243,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:36.7840686Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":5,"playSeq":6,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:37.9140709Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[8],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
|
||||
{"ts":"2026-06-07T12:05:42.3100693Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":6,"playSeq":7}}
|
||||
{"ts":"2026-06-07T12:05:42.3835692Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,8],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}}]}}
|
||||
{"ts":"2026-06-07T12:05:42.7575705Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":7,"playSeq":8,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:42.7750675Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"143","key2":"14","key3":"0","key4":"141","key5":"56","key6":"0"}}}
|
||||
{"ts":"2026-06-07T12:05:42.7905712Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":7,"playSeq":9,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:42.8590737Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[10,16],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"avarice":1}}],"actionSeq":2}}
|
||||
{"ts":"2026-06-07T12:05:46.4565675Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,2,3,10,16],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}},{"trigger":{"isSelf":1,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:05:46.9540693Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"142","key2":"134","key3":"0","key4":"141","key5":"56","key6":"0"},"type":0,"actionSeq":4,"cemetery":[0,0]}}
|
||||
{"ts":"2026-06-07T12:05:47.5195696Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":11,"playSeq":10,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:47.5415707Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[29],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:05:54.7275709Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":12,"playSeq":11,"playIdx":8,"type":30,"knownList":[{"idx":8,"cardId":102331010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:05:54.9510692Z","direction":"send","uri":"Echo","body":{"playIdx":8,"orderList":[{"move":{"idx":[8],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:05:55.7230693Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":13,"playSeq":12}}
|
||||
{"ts":"2026-06-07T12:05:56.2255669Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":14,"playSeq":13,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:56.9100687Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:05:57.0275696Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"142","key2":"134","key3":"0","key4":"143","key5":"140","key6":"102331036"}}}
|
||||
{"ts":"2026-06-07T12:05:57.0415684Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":14,"playSeq":14,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:05:57.0740682Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[15],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":7}}
|
||||
{"ts":"2026-06-07T12:06:12.6129250Z","direction":"send","uri":"PlayActions","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:16.4794226Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,10,16,15],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:06:16.9789227Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"144","key2":"177","key3":"102131049","key4":"143","key5":"140","key6":"102331036"},"type":0,"actionSeq":10,"cemetery":[0,0]}}
|
||||
{"ts":"2026-06-07T12:06:17.0619236Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":19,"playSeq":15,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:17.0839228Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[3],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
|
||||
{"ts":"2026-06-07T12:06:28.8204242Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":20,"playSeq":16,"playIdx":3,"type":30,"knownList":[{"idx":3,"cardId":101321040,"to":20,"spellboost":0,"attachTarget":"","cost":2,"clan":3,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:06:29.0409223Z","direction":"send","uri":"Echo","body":{"playIdx":3,"orderList":[{"move":{"idx":[3],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:29.8804238Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":21,"playSeq":17}}
|
||||
{"ts":"2026-06-07T12:06:30.3639243Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":22,"playSeq":18,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:30.9664239Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:06:31.1154246Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"144","key2":"177","key3":"102131049","key4":"145","key5":"140","key6":"203652104"}}}
|
||||
{"ts":"2026-06-07T12:06:31.1309231Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":22,"playSeq":19,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:31.1914245Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[24],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":13}}
|
||||
{"ts":"2026-06-07T12:06:36.0239226Z","direction":"send","uri":"PlayActions","body":{"playIdx":1,"targetList":[{"targetIdx":8,"isSelf":0}],"orderList":[{"move":{"idx":[1],"isSelf":1,"from":20,"to":30}},{"move":{"idx":[8],"isSelf":0,"from":20,"to":30}}],"type":10}}
|
||||
{"ts":"2026-06-07T12:06:36.6854243Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,10,16,15,24],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}}]}}
|
||||
{"ts":"2026-06-07T12:06:37.1859231Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"148","key2":"321","key3":"0","key4":"147","key5":"140","key6":"101321058"},"type":0,"actionSeq":16,"cemetery":[1,1]}}
|
||||
{"ts":"2026-06-07T12:06:38.2359235Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":27,"playSeq":20,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:38.2569229Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":2}},{"move":{"idx":[19],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:06:46.5794252Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":30,"playSeq":21,"playIdx":29,"type":30,"knownList":[{"idx":29,"cardId":127011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"keyAction":[{"type":1,"cardId":127011010}]}}
|
||||
{"ts":"2026-06-07T12:06:47.0374244Z","direction":"send","uri":"Echo","body":{"playIdx":29,"orderList":[{"move":{"idx":[29],"isSelf":0,"from":10,"to":20}},{"add":{"idx":[41],"isSelf":0,"card":{"candidates":[121011010,120011010]},"isChoice":"1"}},{"move":{"idx":[41],"isSelf":0,"from":50,"to":10}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:50.3279267Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":31,"playSeq":22}}
|
||||
{"ts":"2026-06-07T12:06:50.3444241Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,19,41],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
|
||||
{"ts":"2026-06-07T12:06:50.8274230Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":32,"playSeq":23,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:50.8434224Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"148","key2":"321","key3":"0","key4":"149","key5":"305","key6":"228332150"}}}
|
||||
{"ts":"2026-06-07T12:06:50.8594230Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":31,"playSeq":24,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:50.9024228Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[11],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":19}}
|
||||
{"ts":"2026-06-07T12:06:55.3169242Z","direction":"send","uri":"PlayActions","body":{"playIdx":10,"orderList":[{"move":{"idx":[10],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:06:56.0779247Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}}]}}
|
||||
{"ts":"2026-06-07T12:06:56.5774224Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"150","key2":"302","key3":"101121116","key4":"149","key5":"305","key6":"228332150"},"type":0,"actionSeq":22,"cemetery":[1,1]}}
|
||||
{"ts":"2026-06-07T12:06:57.6284227Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":37,"playSeq":25,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:06:57.6504253Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[39],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
|
||||
{"ts":"2026-06-07T12:07:02.6859240Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":38,"playSeq":26,"playIdx":39,"type":30,"knownList":[{"idx":39,"cardId":100314010,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:07:02.8454236Z","direction":"send","uri":"Echo","body":{"playIdx":39,"orderList":[{"move":{"idx":[39],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[1,4,5,19,41],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"7"}},{"move":{"idx":[17],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}},{"trigger":{"isSelf":0,"avarice":1}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:10.2264230Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":39,"playSeq":27,"playIdx":41,"type":30,"knownList":[{"idx":41,"cardId":121011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:07:10.3164226Z","direction":"send","uri":"Echo","body":{"playIdx":41,"orderList":[{"move":{"idx":[41],"isSelf":0,"from":10,"to":20}},{"move":{"idx":[6],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:17.7599274Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":40,"playSeq":28}}
|
||||
{"ts":"2026-06-07T12:07:17.7789256Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,19,17,6],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}},{"trigger":{"isSelf":0,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:18.2769237Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":41,"playSeq":29,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:18.2949243Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"150","key2":"302","key3":"101121116","key4":"147","key5":"221","key6":"349343345"}}}
|
||||
{"ts":"2026-06-07T12:07:18.3089265Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":40,"playSeq":30,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:18.3409222Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[6],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":26}}
|
||||
{"ts":"2026-06-07T12:07:22.0604232Z","direction":"send","uri":"PlayActions","body":{"playIdx":6,"orderList":[{"move":{"idx":[6],"isSelf":1,"from":10,"to":20}},{"move":{"idx":[34],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}},{"trigger":{"isSelf":1,"avarice":1}}],"uList":[{"idxList":[34],"from":0,"to":10,"isSelf":1,"skill":"6|19|0"}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:25.6229734Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,15,24,11,34],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}},{"trigger":{"isSelf":1,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:26.1224220Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"149","key2":"540","key3":"214132162","key4":"147","key5":"221","key6":"349343345"},"type":0,"actionSeq":29,"cemetery":[1,2]}}
|
||||
{"ts":"2026-06-07T12:07:26.2219233Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":46,"playSeq":31,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:26.2444230Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[32],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:34.2504226Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":49,"playSeq":32,"playIdx":1,"type":31,"knownList":[{"idx":1,"cardId":101324040,"to":30,"spellboost":0,"attachTarget":"","cost":3,"clan":3,"tribe":"0"}],"oppoTargetList":[{"targetIdx":6,"isSelf":1}]}}
|
||||
{"ts":"2026-06-07T12:07:34.4124257Z","direction":"send","uri":"Echo","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[4,5,19,17,6,32],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"20"}},{"move":{"idx":[23],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}},{"trigger":{"isSelf":0,"avarice":1}}],"type":31}}
|
||||
{"ts":"2026-06-07T12:07:41.2491729Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":50,"playSeq":33,"playIdx":17,"type":30,"knownList":[{"idx":17,"cardId":102324040,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:07:41.4554809Z","direction":"send","uri":"Echo","body":{"playIdx":17,"orderList":[{"move":{"idx":[17],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[4,5,19,6,32,23],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"23"}},{"add":{"idx":[42],"isSelf":0,"card":{"cardId":900311050}}},{"move":{"idx":[42],"isSelf":0,"from":50,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:46.6891818Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":52,"playSeq":34,"playIdx":41,"type":10,"oppoTargetList":[{"targetIdx":10,"isSelf":0}]}}
|
||||
{"ts":"2026-06-07T12:07:46.7161815Z","direction":"send","uri":"Echo","body":{"playIdx":41,"type":10}}
|
||||
{"ts":"2026-06-07T12:07:48.2401820Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":54,"playSeq":35,"playIdx":29,"type":10,"knownList":[{"idx":29,"cardId":127011010,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"oppoTargetList":[{"targetIdx":10,"isSelf":0}]}}
|
||||
{"ts":"2026-06-07T12:07:48.3302904Z","direction":"send","uri":"Echo","body":{"playIdx":29,"orderList":[{"move":{"idx":[29],"isSelf":0,"from":20,"to":30}},{"move":{"idx":[10],"isSelf":1,"from":20,"to":30}}],"type":10}}
|
||||
{"ts":"2026-06-07T12:07:50.0089639Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":56,"playSeq":36,"playIdx":3,"type":10,"knownList":[{"idx":3,"cardId":101321040,"to":30,"spellboost":0,"attachTarget":"","cost":2,"clan":3,"tribe":"0"}],"oppoTargetList":[{"targetIdx":6,"isSelf":0}]}}
|
||||
{"ts":"2026-06-07T12:07:50.2322631Z","direction":"send","uri":"Echo","body":{"playIdx":3,"orderList":[{"move":{"idx":[3],"isSelf":0,"from":20,"to":30}},{"move":{"idx":[6],"isSelf":1,"from":20,"to":30}}],"type":10}}
|
||||
{"ts":"2026-06-07T12:07:51.8934054Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":57,"playSeq":37}}
|
||||
{"ts":"2026-06-07T12:07:52.0776073Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[4,5,19,6,32,23],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":5}},{"trigger":{"isSelf":0,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:07:52.3771546Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":58,"playSeq":38,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:52.3931550Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"153","key2":"540","key3":"0","key4":"154","key5":"393","key6":"1021322270"}}}
|
||||
{"ts":"2026-06-07T12:07:52.4097475Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":52,"playSeq":39,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:07:52.4689367Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":6}},{"move":{"idx":[18],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":36}}
|
||||
{"ts":"2026-06-07T12:07:57.1625968Z","direction":"send","uri":"PlayActions","body":{"playIdx":34,"orderList":[{"move":{"idx":[34],"isSelf":1,"from":10,"to":20}},{"move":{"idx":[5],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}},{"trigger":{"isSelf":1,"avarice":1}}],"uList":[{"idxList":[5],"from":0,"to":10,"isSelf":1,"skill":"34|28|0"}],"type":30}}
|
||||
{"ts":"2026-06-07T12:07:58.2473269Z","direction":"send","uri":"PlayActions","body":{"playIdx":18,"orderList":[{"move":{"idx":[18],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:02.7615951Z","direction":"send","uri":"PlayActions","body":{"playIdx":5,"orderList":[{"move":{"idx":[5],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:05.7352832Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":1}},{"move":{"idx":[42],"isSelf":0,"from":20,"to":30,"hasGuard":[42]}},{"trigger":{"isSelf":1,"avarice":0}}]}}
|
||||
{"ts":"2026-06-07T12:08:06.2301214Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"152","key2":"302","key3":"326133205","key4":"156","key5":"393","key6":"121011060"},"type":0,"actionSeq":41,"cemetery":[3,7]}}
|
||||
{"ts":"2026-06-07T12:08:06.3303197Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":65,"playSeq":40,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:06.3513355Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[38],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
|
||||
{"ts":"2026-06-07T12:08:17.8463749Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":68,"playSeq":41,"playIdx":19,"type":30,"knownList":[{"idx":19,"cardId":127011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"keyAction":[{"type":1,"cardId":127011010}]}}
|
||||
{"ts":"2026-06-07T12:08:17.9224312Z","direction":"send","uri":"Echo","body":{"playIdx":19,"orderList":[{"move":{"idx":[19],"isSelf":0,"from":10,"to":20}},{"add":{"idx":[43],"isSelf":0,"card":{"candidates":[121011010,120011010]},"isChoice":"1"}},{"move":{"idx":[43],"isSelf":0,"from":50,"to":10}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:21.3856074Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":71,"playSeq":42,"playIdx":4,"type":31,"knownList":[{"idx":4,"cardId":101324050,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"oppoTargetList":[{"targetIdx":5,"isSelf":0}]}}
|
||||
{"ts":"2026-06-07T12:08:21.5844099Z","direction":"send","uri":"Echo","body":{"playIdx":4,"orderList":[{"move":{"idx":[4],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[5,6,32,23,38,43],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"33"}},{"metamorphose":{"idx":[5],"isSelf":1,"after":{"cardId":900311020}}}],"type":31}}
|
||||
{"ts":"2026-06-07T12:08:25.9743530Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":74,"playSeq":43,"playIdx":5,"type":31,"knownList":[{"idx":5,"cardId":101334030,"to":30,"spellboost":2,"attachTarget":"","cost":3,"clan":3,"tribe":"0"}],"oppoTargetList":[{"targetIdx":34,"isSelf":0}]}}
|
||||
{"ts":"2026-06-07T12:08:26.1638091Z","direction":"send","uri":"Echo","body":{"playIdx":5,"orderList":[{"move":{"idx":[5],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[6,32,23,38,43],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"36"}},{"move":{"idx":[34],"isSelf":1,"from":20,"to":30}},{"add":{"idx":[44],"isSelf":0,"card":{"cardId":900334010}}},{"move":{"idx":[44],"isSelf":0,"from":50,"to":10}}],"type":31}}
|
||||
{"ts":"2026-06-07T12:08:29.6025555Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":75,"playSeq":44}}
|
||||
{"ts":"2026-06-07T12:08:29.6190527Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[6,32,23,38,43,44],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}}]}}
|
||||
{"ts":"2026-06-07T12:08:30.1015223Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":76,"playSeq":45,"turnState":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:30.1180409Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"154","key2":"302","key3":"1000422107","key4":"162","key5":"770","key6":"248022140"}}}
|
||||
{"ts":"2026-06-07T12:08:30.1345601Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":64,"playSeq":46,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:30.1768361Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[35],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":46}}
|
||||
{"ts":"2026-06-07T12:08:37.7130477Z","direction":"send","uri":"PlayActions","body":{"playIdx":15,"orderList":[{"move":{"idx":[15],"isSelf":1,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:38.5902629Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,24,11,35],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":5}}]}}
|
||||
{"ts":"2026-06-07T12:08:39.0894170Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"156","key2":"417","key3":"1101543355","key4":"162","key5":"770","key6":"248022140"},"type":0,"actionSeq":49,"cemetery":[4,9]}}
|
||||
{"ts":"2026-06-07T12:08:40.0572510Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":81,"playSeq":47,"spin":0,"resultCode":1}}
|
||||
{"ts":"2026-06-07T12:08:40.0784574Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[20],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
|
||||
{"ts":"2026-06-07T12:08:44.0705950Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":82,"playSeq":48,"playIdx":20,"type":30,"knownList":[{"idx":20,"cardId":101311010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:08:44.2734716Z","direction":"send","uri":"Echo","body":{"playIdx":20,"orderList":[{"move":{"idx":[20],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:08:49.2868793Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":83,"playSeq":49,"playIdx":23,"type":30,"knownList":[{"idx":23,"cardId":101321070,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
|
||||
{"ts":"2026-06-07T12:08:49.5008504Z","direction":"send","uri":"Echo","body":{"playIdx":23,"orderList":[{"move":{"idx":[23],"isSelf":0,"from":10,"to":20}}],"type":30}}
|
||||
{"ts":"2026-06-07T12:09:11.1269227Z","direction":"receive","uri":null,"body":{"uri":"BattleFinish","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"result":201,"resultCode":1}}
|
||||
64
SVSim.BattleEngine.Tests/HeadlessHandViewStubTests.cs
Normal file
64
SVSim.BattleEngine.Tests/HeadlessHandViewStubTests.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using NUnit.Framework;
|
||||
using Wizard.Battle.View;
|
||||
using Wizard.Battle.View.Vfx;
|
||||
|
||||
namespace SVSim.BattleEngine.Tests
|
||||
{
|
||||
// Regression for the Heal-triggered Skill_heal NRE diagnosed 2026-06-07 (bid 799755786270).
|
||||
//
|
||||
// A follower with a `when_spell_play` Heal trigger fires on a spell play and routes through
|
||||
// Skill_heal.Start → ClassBattleCardBase.ApplyHealing → CreatePullHandInVfx
|
||||
// → HandViewBase.HandUnfocus (HandViewBase.cs:124-131)
|
||||
// The base implementation does `_handControl.SetHandState(HandControl.HandState.Unfocus)`.
|
||||
// HeadlessHandViewStub.CreateHandControl returns null in headless, so `_handControl` is null
|
||||
// and the base method NREs unconditionally — even when the heal amount is 0.
|
||||
//
|
||||
// The fix overrides HandUnfocus/HandFocus/FocusRearrangeHandHand on the stub to return
|
||||
// NullVfx without touching `_handControl`. These are PURE PRESENTATION methods (visual
|
||||
// ease-in/ease-out of the hand cards) — no game-state implications — so no-op'ing them
|
||||
// headless is safe; the surrounding state mutations in ApplyHealing (HealLife, skill triggers)
|
||||
// still run.
|
||||
//
|
||||
// Pattern parity with the metamorphose-NRE shim fix in ViewUiTouchStubs.cs (BattleCardView.GameObject
|
||||
// lazy non-null): production Unity touches that the headless engine must no-op rather than throw.
|
||||
[TestFixture]
|
||||
public class HeadlessHandViewStubTests
|
||||
{
|
||||
[Test]
|
||||
public void HandUnfocus_does_not_throw_and_returns_non_null_vfx()
|
||||
{
|
||||
var stub = HeadlessHandViewStub.Instance;
|
||||
|
||||
VfxBase vfx = null;
|
||||
Assert.DoesNotThrow(() => vfx = stub.HandUnfocus(),
|
||||
"HandUnfocus must no-op headlessly — the live regression (bid 799755786270) crashed " +
|
||||
"Skill_heal.Start when a when_spell_play Heal trigger fired with heal:0 because the " +
|
||||
"base HandUnfocus dereferences a null _handControl.");
|
||||
Assert.That(vfx, Is.Not.Null, "must return a non-null Vfx (caller registers it on a sequential player).");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HandFocus_does_not_throw_and_returns_non_null_vfx()
|
||||
{
|
||||
var stub = HeadlessHandViewStub.Instance;
|
||||
|
||||
VfxBase vfx = null;
|
||||
Assert.DoesNotThrow(() => vfx = stub.HandFocus(),
|
||||
"HandFocus is the sister cosmetic touch (called from CreatePullHandOutVfx on the " +
|
||||
"OWNER's turn). Same null _handControl, same headless no-op required.");
|
||||
Assert.That(vfx, Is.Not.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FocusRearrangeHandHand_does_not_throw_and_returns_non_null_vfx()
|
||||
{
|
||||
var stub = HeadlessHandViewStub.Instance;
|
||||
|
||||
VfxBase vfx = null;
|
||||
Assert.DoesNotThrow(() => vfx = stub.FocusRearrangeHandHand(),
|
||||
"FocusRearrangeHandHand reads _handControl.IsHandStateFocus() before dispatching to " +
|
||||
"HandFocus or HandUnfocus; the base implementation would NRE on the read.");
|
||||
Assert.That(vfx, Is.Not.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Engine;
|
||||
|
||||
namespace SVSim.BattleEngine.Tests.SessionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// PHASE 4 — DECISIVE VERIFICATION (TEST-ONLY, no production fix, no Engine/*.cs edits).
|
||||
///
|
||||
/// QUESTION: does feeding the headless shadow engine the FULL client inputs (server-authored
|
||||
/// Deal/Swap/Ready setup frames for BOTH seats + the real per-seat <c>idxChangeSeed</c>) make its
|
||||
/// recovery-mode draw recompute faithful, so the "Target card was not found in hand cards"
|
||||
/// divergences vanish?
|
||||
///
|
||||
/// This builds the explicit 2x2 {setup-frames ingested: yes/no} x {real seed: yes/no} divergence
|
||||
/// table over the SAME fresh battle (907324319325, battle_test_fresh_cl1/cl2.ndjson), and — at the
|
||||
/// FIRST remaining divergence — dumps the engine's hand indices/ids vs the wire's <c>playIdx</c>.
|
||||
///
|
||||
/// SEEDING MECHANISM (clean, both seats): the seat-B <c>Ready</c> ingest throws an NRE headless (the
|
||||
/// recovery deal path isn't headless-clean for the opponent seat), so the wire <c>Ready</c> cannot be
|
||||
/// relied on to seat seat B's XorShift. To inject the real seed FAITHFULLY for BOTH seats without
|
||||
/// depending on the throwing Ready, we call the test seam <see cref="SessionBattleEngine"/>.
|
||||
/// <c>DebugSeedIdxChange(self, oppo)</c> (-> <c>BattleManagerBase.CreateXorShift</c>) BEFORE the
|
||||
/// mulligan-end frame, with the real per-seat seeds (seat A = cl1's Ready idxChangeSeed = 1430655717,
|
||||
/// seat B = cl2's = 661650374). We ASSERT both <c>SelfXorShiftActive</c> and <c>OppoXorShiftActive</c>
|
||||
/// are true after.
|
||||
///
|
||||
/// SETUP-FRAME INGEST: identical mechanism to <see cref="CaptureReplayReshuffleRootCauseTests"/> — a
|
||||
/// single <c>Deal</c> (cl1's receive Deal seats BOTH hands), each seat's <c>Swap</c> (its mulligan),
|
||||
/// each seat's <c>Ready</c> (mulligan-end). The {no-setup-frames} row SKIPS Deal/Swap/Ready entirely:
|
||||
/// the engine's autonomous Setup hand stands, and we replay only the plays.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class CaptureReplayFullInputDivergenceExperimentTests
|
||||
{
|
||||
// Real per-seat idxChangeSeed carried by each client's Ready frame (given in the experiment brief;
|
||||
// re-confirmed below against the captures).
|
||||
private const int SeatASeed = 1430655717; // cl1 / seat A / player
|
||||
private const int SeatBSeed = 661650374; // cl2 / seat B / opponent
|
||||
|
||||
private static readonly HashSet<string> SkipUris = new()
|
||||
{
|
||||
nameof(NetworkBattleUri.Echo),
|
||||
nameof(NetworkBattleUri.ChatStamp),
|
||||
nameof(NetworkBattleUri.Gungnir),
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> MulliganUris = new()
|
||||
{
|
||||
nameof(NetworkBattleUri.Deal),
|
||||
nameof(NetworkBattleUri.Swap),
|
||||
nameof(NetworkBattleUri.Ready),
|
||||
};
|
||||
|
||||
private sealed record HandDump(string Seat, int PlayIdx, string Uri, string Reason,
|
||||
IReadOnlyList<(int Index, int CardId)> SelfHand,
|
||||
IReadOnlyList<(int Index, int CardId)> OppoHand,
|
||||
bool PlayIdxInSelfHand, bool PlayIdxInOppoHand);
|
||||
|
||||
private sealed record Cell(
|
||||
bool SetupFrames, bool RealSeed,
|
||||
int Divergences, bool SelfXorActive, bool OppoXorActive,
|
||||
HandDump? FirstNotFoundDump);
|
||||
|
||||
private static int ReadPlayIdx(string rawBody)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawBody);
|
||||
return doc.RootElement.TryGetProperty("playIdx", out var p) && p.TryGetInt32(out var v) ? v : -1;
|
||||
}
|
||||
|
||||
// Snapshot a seat's hand as (engine Index, CardId) pairs. Reads through the SessionBattleEngine
|
||||
// oracle accessors (HandCount/HandCardIndex/HandCardId).
|
||||
private static List<(int, int)> HandSnapshot(SessionBattleEngine engine, bool seat)
|
||||
{
|
||||
var list = new List<(int, int)>();
|
||||
int n = engine.HandCount(seat);
|
||||
for (int i = 0; i < n; i++)
|
||||
list.Add((engine.HandCardIndex(seat, i), engine.HandCardId(seat, i)));
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Cell Run(bool setupFrames, bool realSeed)
|
||||
{
|
||||
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
|
||||
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
|
||||
|
||||
var deckA = CaptureReplay.SelfDeckFrom(cl1);
|
||||
var deckB = CaptureReplay.SelfDeckFrom(cl2);
|
||||
Assert.That(deckA, Is.Not.Empty);
|
||||
Assert.That(deckB, Is.Not.Empty);
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
|
||||
Assert.That(engine.IsReady, Is.True);
|
||||
|
||||
// Inject the real per-seat seed BEFORE mulligan-end (Ready). Clean both-seat activation via the
|
||||
// CreateXorShift seam, sidestepping the seat-B Ready NRE.
|
||||
if (realSeed)
|
||||
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
|
||||
|
||||
int divergences = 0;
|
||||
HandDump? firstNotFound = null;
|
||||
|
||||
void Ingest(MsgEnvelope env, bool seat, string uri, string rawBody)
|
||||
{
|
||||
var r = engine.Receive(env, isPlayerSeat: seat);
|
||||
if (!r.Diverged) return;
|
||||
divergences++;
|
||||
if (firstNotFound is null && (r.RejectReason ?? "").Contains("not found in hand"))
|
||||
{
|
||||
int playIdx = ReadPlayIdx(rawBody);
|
||||
var self = HandSnapshot(engine, seat);
|
||||
var oppo = HandSnapshot(engine, !seat);
|
||||
firstNotFound = new HandDump(
|
||||
seat ? "A" : "B", playIdx, uri, Trim(r.RejectReason),
|
||||
self, oppo,
|
||||
self.Any(h => h.Item1 == playIdx), oppo.Any(h => h.Item1 == playIdx));
|
||||
}
|
||||
}
|
||||
|
||||
CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
|
||||
frames.First(f => f.Direction == "receive" && f.Uri == uri);
|
||||
|
||||
// --- Phase 1: setup frames (optional) ---------------------------------------------------------
|
||||
if (setupFrames)
|
||||
{
|
||||
var deal = Receive(cl1, nameof(NetworkBattleUri.Deal));
|
||||
Ingest(deal.Env, seat: true, nameof(NetworkBattleUri.Deal), deal.RawBody);
|
||||
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
|
||||
{
|
||||
var swap = Receive(frames, nameof(NetworkBattleUri.Swap));
|
||||
Ingest(swap.Env, seat, nameof(NetworkBattleUri.Swap), swap.RawBody);
|
||||
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
|
||||
Ingest(ready.Env, seat, nameof(NetworkBattleUri.Ready), ready.RawBody);
|
||||
}
|
||||
}
|
||||
|
||||
bool selfActive = engine.SelfXorShiftActive;
|
||||
bool oppoActive = engine.OppoXorShiftActive;
|
||||
|
||||
// Snapshot the engine's post-setup hands (after Deal/Swap/Ready) for the full-inputs cell, so the
|
||||
// report can compare the engine's mulligan-resolved hand against the wire's Swap/Ready move list.
|
||||
if (setupFrames && realSeed)
|
||||
{
|
||||
TestContext.WriteLine(" [post-setup] engine SELF (seat A) hand: " +
|
||||
string.Join(" ", HandSnapshot(engine, true).Select(h => $"(idx={h.Item1},cid={h.Item2})")));
|
||||
TestContext.WriteLine(" [post-setup] engine OPPO (seat B) hand: " +
|
||||
string.Join(" ", HandSnapshot(engine, false).Select(h => $"(idx={h.Item1},cid={h.Item2})")));
|
||||
}
|
||||
|
||||
// --- Phase 2: replay both clients' interleaved SENDS (the plays) ------------------------------
|
||||
var sends = SendsWithRawBody(cl1, cl2)
|
||||
.Where(x => !SkipUris.Contains(x.Frame.Uri))
|
||||
.ToList();
|
||||
foreach (var x in sends)
|
||||
Ingest(x.Frame.Env, x.Seat, x.Frame.Uri, x.Frame.RawBody);
|
||||
|
||||
return new Cell(setupFrames, realSeed, divergences, selfActive, oppoActive, firstNotFound);
|
||||
}
|
||||
|
||||
private static IEnumerable<(CapturedFrame Frame, bool Seat)> SendsWithRawBody(
|
||||
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> cl2)
|
||||
{
|
||||
return cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
|
||||
.Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
|
||||
.OrderBy(x => x.f.Ts)
|
||||
.Select(x => (x.f, x.Seat));
|
||||
}
|
||||
|
||||
private static string Trim(string? s) => (s ?? "").Split(" @ ")[0];
|
||||
|
||||
[Test]
|
||||
public void Full_input_2x2_divergence_table_and_first_remaining_divergence_dump()
|
||||
{
|
||||
// Confirm the brief's per-seat seeds match the captures' Ready frames before relying on them.
|
||||
ConfirmReadySeeds();
|
||||
|
||||
var cells = new[]
|
||||
{
|
||||
Run(setupFrames: false, realSeed: false), // baseline-ish: autonomous Setup hand, seed -1
|
||||
Run(setupFrames: false, realSeed: true),
|
||||
Run(setupFrames: true, realSeed: false),
|
||||
Run(setupFrames: true, realSeed: true), // FULL INPUTS
|
||||
};
|
||||
|
||||
TestContext.WriteLine("=== 2x2 DIVERGENCE TABLE (setup-frames x real-seed) ===");
|
||||
TestContext.WriteLine("setupFrames | realSeed | divergences | selfXor | oppoXor");
|
||||
foreach (var c in cells)
|
||||
TestContext.WriteLine(
|
||||
$" {(c.SetupFrames ? "YES" : "no ")} | {(c.RealSeed ? "YES" : "no ")} | {c.Divergences,2} | {c.SelfXorActive,-5} | {c.OppoXorActive,-5}");
|
||||
|
||||
var full = cells.Single(c => c.SetupFrames && c.RealSeed);
|
||||
TestContext.WriteLine("");
|
||||
TestContext.WriteLine($"FULL-INPUTS cell: setupFrames=YES realSeed=YES -> divergences={full.Divergences} " +
|
||||
$"selfXorActive={full.SelfXorActive} oppoXorActive={full.OppoXorActive}");
|
||||
|
||||
if (full.FirstNotFoundDump is { } d)
|
||||
{
|
||||
TestContext.WriteLine("");
|
||||
TestContext.WriteLine("=== FIRST 'not found in hand' DIVERGENCE (full-inputs cell) ===");
|
||||
TestContext.WriteLine($" seat={d.Seat} uri={d.Uri} wire playIdx={d.PlayIdx} reason={d.Reason}");
|
||||
TestContext.WriteLine($" playIdx in self hand? {d.PlayIdxInSelfHand} in oppo hand? {d.PlayIdxInOppoHand}");
|
||||
TestContext.WriteLine($" engine SELF (seat {d.Seat}) hand [{d.SelfHand.Count}]: " +
|
||||
string.Join(" ", d.SelfHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
|
||||
TestContext.WriteLine($" engine OPPO hand [{d.OppoHand.Count}]: " +
|
||||
string.Join(" ", d.OppoHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
|
||||
}
|
||||
else
|
||||
{
|
||||
TestContext.WriteLine("");
|
||||
TestContext.WriteLine("FULL-INPUTS cell produced NO 'not found in hand' divergence.");
|
||||
}
|
||||
|
||||
// EVIDENCE ASSERTIONS (pin the experiment's reproducibility, not a desired fix outcome):
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// The seed seam activates BOTH seats' XorShift in every realSeed cell.
|
||||
foreach (var c in cells.Where(c => c.RealSeed))
|
||||
{
|
||||
Assert.That(c.SelfXorActive, Is.True,
|
||||
$"realSeed cell (setup={c.SetupFrames}) must activate self XorShift");
|
||||
Assert.That(c.OppoXorActive, Is.True,
|
||||
$"realSeed cell (setup={c.SetupFrames}) must activate oppo XorShift");
|
||||
}
|
||||
// With NO seed seam AND NO setup frames (the live shadow's effective state — never
|
||||
// ingests the seed-bearing Ready), BOTH seats' XorShift stay inactive.
|
||||
var bare = cells.Single(c => !c.RealSeed && !c.SetupFrames);
|
||||
Assert.That(bare.SelfXorActive, Is.False, "no-seed/no-setup leaves self XorShift inactive");
|
||||
Assert.That(bare.OppoXorActive, Is.False, "no-seed/no-setup leaves oppo XorShift inactive");
|
||||
|
||||
// With setup frames but no seam, the seat-A Ready frame's own idxChangeSeed activates the
|
||||
// SELF XorShift (seat B's Ready NREs before it can seat oppo) — so self is active, oppo isn't.
|
||||
var setupNoSeam = cells.Single(c => !c.RealSeed && c.SetupFrames);
|
||||
Assert.That(setupNoSeam.SelfXorActive, Is.True,
|
||||
"setup-frames cell: seat-A Ready idxChangeSeed activates self XorShift");
|
||||
Assert.That(setupNoSeam.OppoXorActive, Is.False,
|
||||
"setup-frames cell: seat-B Ready NREs before seating oppo XorShift");
|
||||
|
||||
// THE DECISIVE FINDING: full inputs (setup frames + real seed, both seats' XorShift active)
|
||||
// do NOT eliminate the divergences — they stay at the 14 baseline.
|
||||
var full2 = cells.Single(c => c.SetupFrames && c.RealSeed);
|
||||
Assert.That(full2.SelfXorActive && full2.OppoXorActive, Is.True,
|
||||
"full-inputs cell has both seats' XorShift active");
|
||||
Assert.That(full2.Divergences, Is.GreaterThan(0),
|
||||
"REFUTED: full inputs do NOT make the recovery recompute faithful — divergences remain");
|
||||
});
|
||||
}
|
||||
|
||||
// Re-confirm the brief's per-seat seeds against the captured Ready frames (fail loudly if the
|
||||
// fixtures ever drift from the assumed seeds).
|
||||
private static void ConfirmReadySeeds()
|
||||
{
|
||||
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
|
||||
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
|
||||
int a = ReadReadySeed(cl1);
|
||||
int b = ReadReadySeed(cl2);
|
||||
TestContext.WriteLine($"Confirmed Ready idxChangeSeed: cl1(seatA)={a} cl2(seatB)={b}");
|
||||
Assert.That(a, Is.EqualTo(SeatASeed), "cl1 Ready idxChangeSeed must equal the brief's seat-A seed");
|
||||
Assert.That(b, Is.EqualTo(SeatBSeed), "cl2 Ready idxChangeSeed must equal the brief's seat-B seed");
|
||||
}
|
||||
|
||||
private static int ReadReadySeed(IReadOnlyList<CapturedFrame> frames)
|
||||
{
|
||||
var ready = frames.First(f => f.Direction == "receive" && f.Uri == nameof(NetworkBattleUri.Ready));
|
||||
var obj = JsonNode.Parse(ready.RawBody)!.AsObject();
|
||||
return (int)obj["idxChangeSeed"]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Engine;
|
||||
|
||||
namespace SVSim.BattleEngine.Tests.SessionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// PHASE 4 — DRAW-RECOMPUTE ROOT-CAUSE VALIDATION (TEST-ONLY; no production fix; no Engine/*.cs edits).
|
||||
///
|
||||
/// HYPOTHESIS (from the experiment brief): the shadow diverges ("Target card was not found in hand
|
||||
/// cards", post-mulligan) because the per-turn network DRAW is a SEEDED-RANDOM pick from the deck via
|
||||
/// <c>mgr.StableRandom(...)</c> (SkillRandomSelectFilter.Filtering:49/58), gated by the process-global
|
||||
/// <c>BattleManagerBase.IsRandomDraw</c> — which the real match-load sets true via
|
||||
/// <c>StartOpening → SetupInitialGameState(areCardsRandomlyDrawn:true)</c> (BattleManagerBase.cs:1098/1110).
|
||||
/// The headless <see cref="SessionBattleEngine"/>.Setup never runs SetupInitialGameState, so IsRandomDraw
|
||||
/// stays FALSE and the shadow draws TOP-OF-DECK while the clients draw seeded-random → mismatch.
|
||||
/// AND the shared <c>_stableRandom</c> stream must be advanced by the wire <c>spin</c> pre-roll the Ready
|
||||
/// frame carries (spin=243), which <c>OperateReceive.StartOperate:80-83</c> applies but the shadow never
|
||||
/// ingests — so without it the stream is offset.
|
||||
///
|
||||
/// ISOLATION MATRIX (this is the report's headline): setup frames + real seed are held CONSTANT (the
|
||||
/// faithful baseline the prior FullInput experiment pinned at 14); the two NEW variables are toggled:
|
||||
/// • {IsRandomDraw=false, no spin} = baseline (top-of-deck draws; the live shadow's effective state)
|
||||
/// • {IsRandomDraw=true, no spin} = random-draw active but stream MIS-aligned (expect WORSE)
|
||||
/// • {IsRandomDraw=true, +spin} = random-draw active AND stream aligned (the hypothesised fix)
|
||||
///
|
||||
/// SPIN APPLICATION: spin=243 appears on the Ready frame in BOTH captures (each client applies its own
|
||||
/// once). Our shadow shares ONE <c>_stableRandom</c> across both seats (seated as both players), and a
|
||||
/// single client's stream sits 243 draws in after ITS Ready — so we apply spin=243 ONCE, after the
|
||||
/// Deal/Swap/Ready setup frames and before the plays, exactly where the real client's StartOperate would.
|
||||
/// (A scan of both fixtures confirms Ready is the ONLY frame carrying a non-zero spin.)
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class CaptureReplayRandomDrawSpinRootCauseTests
|
||||
{
|
||||
private const int SeatASeed = 1430655717; // cl1 / seat A / player (Ready idxChangeSeed)
|
||||
private const int SeatBSeed = 661650374; // cl2 / seat B / opponent
|
||||
private const int WireSpin = 243; // both captures' Ready frame spin
|
||||
|
||||
private static readonly HashSet<string> SkipUris = new()
|
||||
{
|
||||
nameof(NetworkBattleUri.Echo),
|
||||
nameof(NetworkBattleUri.ChatStamp),
|
||||
nameof(NetworkBattleUri.Gungnir),
|
||||
};
|
||||
|
||||
private sealed record HandDump(string Seat, int PlayIdx, string Uri, string Reason,
|
||||
int StableRandomCount,
|
||||
IReadOnlyList<(int Index, int CardId)> SelfHand,
|
||||
IReadOnlyList<(int Index, int CardId)> OppoHand,
|
||||
bool PlayIdxInSelfHand, bool PlayIdxInOppoHand);
|
||||
|
||||
private sealed record Cell(bool RandomDraw, bool Spin, int Divergences, HandDump? FirstNotFound);
|
||||
|
||||
private static int ReadPlayIdx(string rawBody)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawBody);
|
||||
return doc.RootElement.TryGetProperty("playIdx", out var p) && p.TryGetInt32(out var v) ? v : -1;
|
||||
}
|
||||
|
||||
private static List<(int, int)> HandSnapshot(SessionBattleEngine engine, bool seat)
|
||||
{
|
||||
var list = new List<(int, int)>();
|
||||
int n = engine.HandCount(seat);
|
||||
for (int i = 0; i < n; i++)
|
||||
list.Add((engine.HandCardIndex(seat, i), engine.HandCardId(seat, i)));
|
||||
return list;
|
||||
}
|
||||
|
||||
private static CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
|
||||
frames.First(f => f.Direction == "receive" && f.Uri == uri);
|
||||
|
||||
private static Cell Run(bool randomDraw, bool spin)
|
||||
{
|
||||
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
|
||||
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
|
||||
|
||||
var deckA = CaptureReplay.SelfDeckFrom(cl1);
|
||||
var deckB = CaptureReplay.SelfDeckFrom(cl2);
|
||||
Assert.That(deckA, Is.Not.Empty);
|
||||
Assert.That(deckB, Is.Not.Empty);
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
|
||||
Assert.That(engine.IsReady, Is.True);
|
||||
|
||||
// CONSTANT across all cells: faithful seed seam (both seats' XorShift active), sidestepping the
|
||||
// seat-B Ready NRE — identical to the FullInput experiment's full-inputs cell.
|
||||
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
|
||||
|
||||
// NEW VARIABLE 1: the IsRandomDraw gate. Set BEFORE any draw (deal is the first draw).
|
||||
engine.DebugSetRandomDraw(randomDraw);
|
||||
|
||||
int divergences = 0;
|
||||
HandDump? firstNotFound = null;
|
||||
|
||||
void Ingest(MsgEnvelope env, bool seat, string uri, string rawBody)
|
||||
{
|
||||
var r = engine.Receive(env, isPlayerSeat: seat);
|
||||
if (!r.Diverged) return;
|
||||
divergences++;
|
||||
if (firstNotFound is null && (r.RejectReason ?? "").Contains("not found in hand"))
|
||||
{
|
||||
var self = HandSnapshot(engine, seat);
|
||||
var oppo = HandSnapshot(engine, !seat);
|
||||
int playIdx = ReadPlayIdx(rawBody);
|
||||
firstNotFound = new HandDump(
|
||||
seat ? "A" : "B", playIdx, uri, Trim(r.RejectReason),
|
||||
engine.DebugStableRandomCount, self, oppo,
|
||||
self.Any(h => h.Item1 == playIdx), oppo.Any(h => h.Item1 == playIdx));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 1: setup frames (CONSTANT: Deal once + each seat's Swap + Ready) -------------------
|
||||
var deal = Receive(cl1, nameof(NetworkBattleUri.Deal));
|
||||
Ingest(deal.Env, seat: true, nameof(NetworkBattleUri.Deal), deal.RawBody);
|
||||
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
|
||||
{
|
||||
var swap = Receive(frames, nameof(NetworkBattleUri.Swap));
|
||||
Ingest(swap.Env, seat, nameof(NetworkBattleUri.Swap), swap.RawBody);
|
||||
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
|
||||
Ingest(ready.Env, seat, nameof(NetworkBattleUri.Ready), ready.RawBody);
|
||||
}
|
||||
|
||||
// NEW VARIABLE 2: the spin pre-roll, applied at mulligan-end (after Ready, before the first
|
||||
// turn-start draw) — where OperateReceive.StartOperate applies the Ready's spin in production.
|
||||
// ONE application of 243 (shared stream, one client's worth of advance).
|
||||
if (spin)
|
||||
engine.DebugSpinPreroll(WireSpin);
|
||||
|
||||
// --- Phase 2: replay both clients' interleaved SENDS (the plays) ------------------------------
|
||||
var sends = SendsWithRawBody(cl1, cl2)
|
||||
.Where(x => !SkipUris.Contains(x.Frame.Uri))
|
||||
.ToList();
|
||||
foreach (var x in sends)
|
||||
Ingest(x.Frame.Env, x.Seat, x.Frame.Uri, x.Frame.RawBody);
|
||||
|
||||
return new Cell(randomDraw, spin, divergences, firstNotFound);
|
||||
}
|
||||
|
||||
private static IEnumerable<(CapturedFrame Frame, bool Seat)> SendsWithRawBody(
|
||||
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> cl2)
|
||||
{
|
||||
return cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
|
||||
.Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
|
||||
.OrderBy(x => x.f.Ts)
|
||||
.Select(x => (x.f, x.Seat));
|
||||
}
|
||||
|
||||
private static string Trim(string? s) => (s ?? "").Split(" @ ")[0];
|
||||
|
||||
[Test]
|
||||
public void IsRandomDraw_plus_spin_preroll_isolation_matrix()
|
||||
{
|
||||
try
|
||||
{
|
||||
ConfirmSpin();
|
||||
|
||||
var baseline = Run(randomDraw: false, spin: false);
|
||||
var rdOnly = Run(randomDraw: true, spin: false);
|
||||
var rdSpin = Run(randomDraw: true, spin: true);
|
||||
|
||||
TestContext.WriteLine("=== ISOLATION MATRIX (setup-frames + real-seed held CONSTANT) ===");
|
||||
TestContext.WriteLine("IsRandomDraw | spin | divergences");
|
||||
TestContext.WriteLine($" false | no | {baseline.Divergences}");
|
||||
TestContext.WriteLine($" true | no | {rdOnly.Divergences}");
|
||||
TestContext.WriteLine($" true | +243 | {rdSpin.Divergences}");
|
||||
|
||||
DumpFirst("baseline {false,no}", baseline);
|
||||
DumpFirst("rd-only {true,no}", rdOnly);
|
||||
DumpFirst("rd+spin {true,+243}", rdSpin);
|
||||
|
||||
Assert.Pass(
|
||||
$"MATRIX baseline={baseline.Divergences} rdOnly={rdOnly.Divergences} rdSpin={rdSpin.Divergences}");
|
||||
}
|
||||
catch (SuccessException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
TestContext.WriteLine("EXPERIMENT THREW: " + ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DumpFirst(string label, Cell c)
|
||||
{
|
||||
if (c.FirstNotFound is not { } d)
|
||||
{
|
||||
TestContext.WriteLine($"[{label}] no 'not found in hand' divergence.");
|
||||
return;
|
||||
}
|
||||
TestContext.WriteLine($"[{label}] FIRST 'not found in hand': seat={d.Seat} uri={d.Uri} " +
|
||||
$"wire playIdx={d.PlayIdx} stableRandomCount={d.StableRandomCount} reason={d.Reason}");
|
||||
TestContext.WriteLine($" playIdx in self hand? {d.PlayIdxInSelfHand} in oppo hand? {d.PlayIdxInOppoHand}");
|
||||
TestContext.WriteLine($" SELF (seat {d.Seat}) hand [{d.SelfHand.Count}]: " +
|
||||
string.Join(" ", d.SelfHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
|
||||
TestContext.WriteLine($" OPPO hand [{d.OppoHand.Count}]: " +
|
||||
string.Join(" ", d.OppoHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
|
||||
}
|
||||
|
||||
/// <summary>STEP 4 (payoff check): with the hypothesised fix applied {IsRandomDraw=true, +spin},
|
||||
/// does the engine reach and RESOLVE cl1's spellboost play so PlayedCardSpellboost/PlayedCardCost
|
||||
/// return real (non-zero) values? cl1's deck carries the spellboost-scaling follower 101314020 at
|
||||
/// deck idx 10/21/25. We replay the {true,+243} cell and, after each accepted seat-A PlayActions,
|
||||
/// probe whether any in-play/cemetery card has that id with a resolved cost/spellboost. We report
|
||||
/// whether the spellboost play was ever reached at all.</summary>
|
||||
[Test]
|
||||
public void Spellboost_play_resolution_under_random_draw_plus_spin()
|
||||
{
|
||||
const int SpellboostCardId = 101314020;
|
||||
|
||||
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
|
||||
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
|
||||
var deckA = CaptureReplay.SelfDeckFrom(cl1);
|
||||
var deckB = CaptureReplay.SelfDeckFrom(cl2);
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
|
||||
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
|
||||
engine.DebugSetRandomDraw(true);
|
||||
|
||||
// setup frames
|
||||
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Deal)).Env, isPlayerSeat: true);
|
||||
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
|
||||
{
|
||||
engine.Receive(Receive(frames, nameof(NetworkBattleUri.Swap)).Env, isPlayerSeat: seat);
|
||||
engine.Receive(Receive(frames, nameof(NetworkBattleUri.Ready)).Env, isPlayerSeat: seat);
|
||||
}
|
||||
engine.DebugSpinPreroll(WireSpin);
|
||||
|
||||
int acceptedSeatAPlays = 0, divergedBeforeFirstPlay = 0;
|
||||
bool spellboostResolved = false;
|
||||
int sbCost = -999, sbCharge = -999;
|
||||
|
||||
var sends = SendsWithRawBody(cl1, cl2).Where(x => !SkipUris.Contains(x.Frame.Uri)).ToList();
|
||||
bool sawFirstPlay = false;
|
||||
foreach (var x in sends)
|
||||
{
|
||||
bool isPlay = x.Frame.Uri == nameof(NetworkBattleUri.PlayActions);
|
||||
var r = engine.Receive(x.Frame.Env, isPlayerSeat: x.Seat);
|
||||
if (isPlay && !sawFirstPlay) { sawFirstPlay = true; if (r.Diverged) divergedBeforeFirstPlay++; }
|
||||
if (isPlay && x.Seat && !r.Diverged)
|
||||
{
|
||||
acceptedSeatAPlays++;
|
||||
int playIdx = ReadPlayIdx(x.Frame.RawBody);
|
||||
long id = engine.PlayedCardId(true, playIdx, 0);
|
||||
if (id == SpellboostCardId)
|
||||
{
|
||||
spellboostResolved = true;
|
||||
sbCost = engine.PlayedCardCost(true, playIdx, -1);
|
||||
sbCharge = engine.PlayedCardSpellboost(true, playIdx, -1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestContext.WriteLine($"[spellboost payoff] acceptedSeatAPlays={acceptedSeatAPlays} " +
|
||||
$"divergedAtFirstPlay={divergedBeforeFirstPlay} spellboostResolved={spellboostResolved} " +
|
||||
$"cost={sbCost} charge={sbCharge}");
|
||||
|
||||
// The replay diverges at the FIRST seat-A play (matrix shows playIdx=8 not in hand), so the
|
||||
// engine never advances to the later spellboost play — the visible spellboost symptom is NOT
|
||||
// fixed by {IsRandomDraw+spin} because the prerequisite (aligned draws) is not met.
|
||||
Assert.That(divergedBeforeFirstPlay, Is.EqualTo(1),
|
||||
"the FIRST seat-A play already diverges under {IsRandomDraw=true,+spin}");
|
||||
Assert.That(spellboostResolved, Is.False,
|
||||
"the spellboost play is never reached because the replay diverges at the first play");
|
||||
}
|
||||
|
||||
private static void ConfirmSpin()
|
||||
{
|
||||
foreach (var fn in new[] { "battle_test_fresh_cl1.ndjson", "battle_test_fresh_cl2.ndjson" })
|
||||
{
|
||||
var frames = CaptureReplay.Load(fn);
|
||||
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
|
||||
var obj = JsonNode.Parse(ready.RawBody)!.AsObject();
|
||||
int spin = obj.TryGetPropertyValue("spin", out var s) ? (int)s! : 0;
|
||||
TestContext.WriteLine($"Confirmed {fn} Ready spin={spin}");
|
||||
Assert.That(spin, Is.EqualTo(WireSpin), $"{fn} Ready spin must equal {WireSpin}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Nodes;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Engine;
|
||||
|
||||
namespace SVSim.BattleEngine.Tests.SessionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// PHASE 4 STEP 1 — Tier 2 capture-replay root-cause VERIFICATION (NOT a fix).
|
||||
///
|
||||
/// Replays the FRESH smoke captures (battle 907324319325) — battle_test_fresh_cl1/cl2.ndjson —
|
||||
/// through a <see cref="SessionBattleEngine"/>, then measures whether the per-seat <c>idxChangeSeed</c>
|
||||
/// the real Ready frame carries is what controls the "Target card was not found in hand cards"
|
||||
/// divergence symptom.
|
||||
///
|
||||
/// FAITHFUL SETUP (the live ShadowIngest only feeds client SENDS, which contain NO Deal/Ready, so a
|
||||
/// bare send-only replay can't even seat a hand — that conflates "missing Deal" with "missing
|
||||
/// reshuffle"). To ISOLATE the reshuffle/seed effect we seat each seat's hand from its OWN client's
|
||||
/// RECEIVE Deal + Swap + Ready (the frames that establish the hand and reach mulligan-end), then replay
|
||||
/// both clients' interleaved SENDS (the plays). The Ready frame natively carries the real per-seat
|
||||
/// idxChangeSeed (cl1=1430655717, cl2=661650374), and the engine's recovery receiver calls
|
||||
/// <c>CreateXorShift</c> from it (NetworkBattleReceiver.cs:1125-1126). The A/B is then:
|
||||
/// • WITH-SEED: ingest the Ready frame verbatim (idxChangeSeed present) -> XorShift active;
|
||||
/// • SEED-STRIPPED: ingest the SAME Ready frame with idxChangeSeed forced to -1 -> XorShift inactive
|
||||
/// (this is exactly the live shadow's effective state, since it never ingests the seed-bearing Ready).
|
||||
/// The ONLY difference between the two runs is whether the seed reaches CreateXorShift.
|
||||
///
|
||||
/// DECK SETUP MECHANISM (feasibility crux, RESOLVED): each side's deck is reconstructed from the
|
||||
/// capture's <c>Matched.selfDeck</c> (idx->cardId, the exact shuffled order the node also handed the
|
||||
/// client) via <see cref="CaptureReplay.SelfDeckFrom"/>; the master seed from <c>Matched.selfInfo.seed</c>.
|
||||
/// The deck IS in the socket capture — no external fixture needed.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class CaptureReplayReshuffleRootCauseTests
|
||||
{
|
||||
private static readonly HashSet<string> SkipUris = new()
|
||||
{
|
||||
nameof(NetworkBattleUri.Echo),
|
||||
nameof(NetworkBattleUri.ChatStamp),
|
||||
nameof(NetworkBattleUri.Gungnir),
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> MulliganUris = new()
|
||||
{
|
||||
nameof(NetworkBattleUri.Deal),
|
||||
nameof(NetworkBattleUri.Swap),
|
||||
nameof(NetworkBattleUri.Ready),
|
||||
};
|
||||
|
||||
private sealed record ReplayOutcome(
|
||||
int FrameCount, List<string> Divergences, bool AllDivergencesPostMulligan, bool SelfXorShiftActive);
|
||||
|
||||
// Re-parse a captured frame, overriding the Ready body's idxChangeSeed (and oppoIdxChangeSeed if
|
||||
// present). Used to STRIP the seed (-1) to model the live shadow's seed-less state.
|
||||
private static MsgEnvelope OverrideReadySeed(CapturedFrame f, int newSeed)
|
||||
{
|
||||
var obj = JsonNode.Parse(f.RawBody)!.AsObject();
|
||||
obj["idxChangeSeed"] = newSeed;
|
||||
if (obj.ContainsKey("oppoIdxChangeSeed")) obj["oppoIdxChangeSeed"] = newSeed;
|
||||
return MsgEnvelope.FromJson(obj.ToJsonString());
|
||||
}
|
||||
|
||||
/// <summary>Seat both hands from each client's receive Deal+Swap+Ready, then replay both clients'
|
||||
/// interleaved SENDS. <paramref name="stripSeed"/> forces the Ready idxChangeSeed to -1 (the live
|
||||
/// shadow's effective state). Returns divergences + the post-setup self XorShift state.</summary>
|
||||
private static ReplayOutcome Replay(bool stripSeed)
|
||||
{
|
||||
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
|
||||
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
|
||||
|
||||
var deckA = CaptureReplay.SelfDeckFrom(cl1);
|
||||
var deckB = CaptureReplay.SelfDeckFrom(cl2);
|
||||
Assert.That(deckA, Is.Not.Empty, "cl1 Matched.selfDeck must reconstruct seat A's deck");
|
||||
Assert.That(deckB, Is.Not.Empty, "cl2 Matched.selfDeck must reconstruct seat B's deck");
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
|
||||
Assert.That(engine.IsReady, Is.True, "engine must seat from the captured decks + seed");
|
||||
|
||||
var divergences = new List<string>();
|
||||
bool sawMulliganEnd = false;
|
||||
bool anyDivergencePreMulligan = false;
|
||||
|
||||
void Ingest(MsgEnvelope env, bool seat, string uri)
|
||||
{
|
||||
if (MulliganUris.Contains(uri)) sawMulliganEnd = true;
|
||||
var r = engine.Receive(env, isPlayerSeat: seat);
|
||||
if (r.Diverged)
|
||||
{
|
||||
divergences.Add($"seat={(seat ? "A" : "B")} {uri}: {Trim(r.RejectReason)}");
|
||||
if (!sawMulliganEnd) anyDivergencePreMulligan = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 1: seat both hands from the receive setup frames ----------------------------------
|
||||
// A single Deal seats BOTH opening hands (cl1's receive Deal carries self=A + oppo=B), so we
|
||||
// ingest Deal ONCE (as seat A) — ingesting both clients' Deals would double-deal (NRE / "Sequence
|
||||
// contains more than one"). Each seat's Swap then applies that seat's mulligan, and each seat's
|
||||
// Ready carries THAT seat's idxChangeSeed (cl1's for A, cl2's for B; the recovery receiver consumes
|
||||
// only the SELF seed per ingest, NetworkBattleReceiver.cs:1126), reaching mulligan-end per seat.
|
||||
CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
|
||||
frames.First(f => f.Direction == "receive" && f.Uri == uri);
|
||||
|
||||
// Deal once (seat A's receive Deal seats both hands).
|
||||
Ingest(Receive(cl1, nameof(NetworkBattleUri.Deal)).Env, seat: true, nameof(NetworkBattleUri.Deal));
|
||||
// Each seat's mulligan swap, then each seat's Ready (its own seed).
|
||||
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
|
||||
{
|
||||
Ingest(Receive(frames, nameof(NetworkBattleUri.Swap)).Env, seat, nameof(NetworkBattleUri.Swap));
|
||||
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
|
||||
var readyEnv = stripSeed ? OverrideReadySeed(ready, -1) : ready.Env;
|
||||
Ingest(readyEnv, seat, nameof(NetworkBattleUri.Ready));
|
||||
}
|
||||
|
||||
bool selfActive = engine.SelfXorShiftActive;
|
||||
|
||||
// --- Phase 2: replay both clients' interleaved SENDS (the plays / turn ops) -------------------
|
||||
var sends = CaptureReplay.InterleavedSends(cl1, cl2)
|
||||
.Where(x => !SkipUris.Contains(x.Env.Uri.ToString()))
|
||||
.ToList();
|
||||
foreach (var (env, seat) in sends)
|
||||
Ingest(env, seat, env.Uri.ToString());
|
||||
|
||||
return new ReplayOutcome(
|
||||
FrameCount: sends.Count, divergences, !anyDivergencePreMulligan, selfActive);
|
||||
}
|
||||
|
||||
private static string Trim(string? s) =>
|
||||
(s ?? "").Split(" @ ")[0];
|
||||
|
||||
[Test]
|
||||
public void Capture_replay_reproduces_post_mulligan_divergence_and_pins_what_the_seed_does_not_fix()
|
||||
{
|
||||
var withSeed = Replay(stripSeed: false);
|
||||
var stripped = Replay(stripSeed: true);
|
||||
|
||||
TestContext.WriteLine($"WITH-SEED (Ready idxChangeSeed present): selfXorShiftActive={withSeed.SelfXorShiftActive} " +
|
||||
$"playFrames={withSeed.FrameCount} divergences={withSeed.Divergences.Count}");
|
||||
foreach (var d in withSeed.Divergences) TestContext.WriteLine(" DIVERGE " + d);
|
||||
TestContext.WriteLine($"SEED-STRIPPED (idxChangeSeed=-1, the live shadow state): selfXorShiftActive={stripped.SelfXorShiftActive} " +
|
||||
$"playFrames={stripped.FrameCount} divergences={stripped.Divergences.Count}");
|
||||
foreach (var d in stripped.Divergences) TestContext.WriteLine(" DIVERGE " + d);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// (1) The reported symptom reproduces DETERMINISTICALLY from the captures: the replay diverges,
|
||||
// including the verbatim "Target card was not found in hand cards" exception.
|
||||
Assert.That(withSeed.Divergences, Is.Not.Empty,
|
||||
"the capture replay must reproduce the divergence symptom");
|
||||
Assert.That(withSeed.Divergences.Any(d => d.Contains("not found in hand")), Is.True,
|
||||
"the reported 'Target card was not found in hand cards' symptom must reproduce");
|
||||
|
||||
// (2) All divergences occur AFTER the mulligan barrier — consistent with a post-mulligan cause.
|
||||
Assert.That(withSeed.AllDivergencesPostMulligan, Is.True, "with-seed divergences are post-mulligan");
|
||||
Assert.That(stripped.AllDivergencesPostMulligan, Is.True, "stripped divergences are post-mulligan");
|
||||
|
||||
// (3) The wire seed DOES drive the engine's XorShift gate (NetworkBattleReceiver.cs:1126):
|
||||
// present -> active, stripped (the live shadow's state) -> inactive.
|
||||
Assert.That(withSeed.SelfXorShiftActive, Is.True,
|
||||
"ingesting the real Ready (idxChangeSeed present) activates the engine's XorShift");
|
||||
Assert.That(stripped.SelfXorShiftActive, Is.False,
|
||||
"stripping idxChangeSeed (the live shadow's state) leaves the XorShift inactive");
|
||||
|
||||
// (4) THE KEY VERIFICATION FINDING — activating the XorShift via the wire seed does NOT, on its
|
||||
// own, change the divergence count. The engine's recovery/watch RECEIVE path never performs
|
||||
// the post-mulligan full-deck reshuffle the live client does: the XorShift's GetChangeInt is
|
||||
// consumed ONLY by AddToDeckCardIndexChange (BattlePlayerBase.cs:3079) for cards added to the
|
||||
// deck AFTER mulligan-end, and the per-turn draw is engine-computed off the (un-reshuffled)
|
||||
// deck order, not driven by the wire's `move idx`. So "feed the seed" alone does NOT fix the
|
||||
// desync headless — the eventual fix must also make the engine reshuffle the deck post-
|
||||
// mulligan to match the client (or drive the draw from the wire idx). We PIN this here.
|
||||
Assert.That(stripped.Divergences.Count, Is.EqualTo(withSeed.Divergences.Count),
|
||||
"VERIFIED: activating the XorShift via the wire seed alone does NOT change the divergence " +
|
||||
"count — the engine's receive path does not reshuffle the deck, so the seed is necessary " +
|
||||
"but NOT sufficient (the fix needs the reshuffle too, not just the seed)");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Engine;
|
||||
|
||||
namespace SVSim.BattleEngine.Tests.SessionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// PHASE 4 — OPTION-A VIABILITY PROBE (TEST-ONLY; no production fix; no Engine/*.cs edits).
|
||||
///
|
||||
/// QUESTION: can a per-seat RNG router in the headless engine reliably attribute each StableRandom roll
|
||||
/// to the correct seat — so two seats can draw from two independent same-seeded sub-streams (mirroring
|
||||
/// two real clients, each with its OWN _stableRandom)?
|
||||
///
|
||||
/// METHOD: replay battle_test_fresh_cl1/cl2 through a <see cref="SessionBattleEngine"/> whose mgr RNG is
|
||||
/// a logging source. On EVERY roll it records (a) the seat signals the mgr can read from its own state
|
||||
/// (GetBattlePlayer(true/false).IsSelfTurn — the richest seat signal a mgr-level StableRandom override
|
||||
/// sees; there is NO "current operating seat" field on the mgr), and (b) the live call stack (where the
|
||||
/// acting seat is actually visible: MulliganCtrl._battlePlayer / BattlePlayerBase.LotteryRandomDrawCard /
|
||||
/// OperateReceive.StartOperate spin pre-roll). We dump the rolls for the mulligan lotteries, the first
|
||||
/// turn draws, and the spin pre-roll, and classify each — reporting whether the seat is UNAMBIGUOUS from
|
||||
/// mgr STATE vs only from the STACK.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class CaptureReplayRngSeatAttributionProbeTests
|
||||
{
|
||||
private const int SeatASeed = 1430655717; // cl1 Ready idxChangeSeed
|
||||
private const int SeatBSeed = 661650374; // cl2 Ready idxChangeSeed
|
||||
private const int WireSpin = 243;
|
||||
|
||||
private static readonly HashSet<string> SkipUris = new()
|
||||
{
|
||||
nameof(NetworkBattleUri.Echo),
|
||||
nameof(NetworkBattleUri.ChatStamp),
|
||||
nameof(NetworkBattleUri.Gungnir),
|
||||
};
|
||||
|
||||
private static CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
|
||||
frames.First(f => f.Direction == "receive" && f.Uri == uri);
|
||||
|
||||
private static IEnumerable<(CapturedFrame Frame, bool Seat)> SendsInTsOrder(
|
||||
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> cl2) =>
|
||||
cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
|
||||
.Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
|
||||
.OrderBy(x => x.f.Ts)
|
||||
.Select(x => (x.f, x.Seat));
|
||||
|
||||
[Test]
|
||||
public void Roll_log_reveals_whether_acting_seat_is_attributable_from_state_or_only_stack()
|
||||
{
|
||||
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
|
||||
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
|
||||
|
||||
var deckA = CaptureReplay.SelfDeckFrom(cl1);
|
||||
var deckB = CaptureReplay.SelfDeckFrom(cl2);
|
||||
|
||||
// (5) seeds
|
||||
int seedA = CaptureReplay.SeedFrom(cl1);
|
||||
int seedB = CaptureReplay.SeedFrom(cl2);
|
||||
TestContext.WriteLine($"=== SEEDS (Matched.selfInfo.seed) ===");
|
||||
TestContext.WriteLine($" cl1 seed = {seedA}");
|
||||
TestContext.WriteLine($" cl2 seed = {seedB}");
|
||||
TestContext.WriteLine($" SAME? {seedA == seedB} (Ready idxChangeSeed cl1={SeatASeed} cl2={SeatBSeed} — DIFFERENT)");
|
||||
TestContext.WriteLine("");
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
var log = engine.DebugSetupWithRollLog(masterSeed: seedA, seatADeck: deckA, seatBDeck: deckB);
|
||||
Assert.That(engine.IsReady, Is.True);
|
||||
|
||||
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
|
||||
engine.DebugSetRandomDraw(true); // the gate that makes draws actually ROLL
|
||||
|
||||
// mark roll-log boundaries so we can bucket the rolls by phase
|
||||
int Mark() => log.Count;
|
||||
|
||||
int beforeDeal = Mark();
|
||||
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Deal)).Env, isPlayerSeat: true);
|
||||
int afterDeal = Mark();
|
||||
|
||||
// seat A mulligan (Swap+Ready) then seat B mulligan
|
||||
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Swap)).Env, isPlayerSeat: true);
|
||||
int afterSwapA = Mark();
|
||||
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Ready)).Env, isPlayerSeat: true);
|
||||
int afterReadyA = Mark();
|
||||
engine.Receive(Receive(cl2, nameof(NetworkBattleUri.Swap)).Env, isPlayerSeat: false);
|
||||
int afterSwapB = Mark();
|
||||
engine.Receive(Receive(cl2, nameof(NetworkBattleUri.Ready)).Env, isPlayerSeat: false);
|
||||
int afterReadyB = Mark();
|
||||
|
||||
// spin pre-roll (one client's 243 advance, applied once on the shared stream)
|
||||
engine.DebugSpinPreroll(WireSpin);
|
||||
int afterSpin = Mark();
|
||||
|
||||
// replay both clients' interleaved sends (the plays + turn ops -> turn-start draws fire here)
|
||||
var sends = SendsInTsOrder(cl1, cl2).Where(x => !SkipUris.Contains(x.Frame.Uri)).ToList();
|
||||
foreach (var x in sends)
|
||||
engine.Receive(x.Frame.Env, isPlayerSeat: x.Seat);
|
||||
int afterSends = Mark();
|
||||
|
||||
TestContext.WriteLine("=== ROLL-COUNT BY PHASE (IsRandomDraw=true) ===");
|
||||
TestContext.WriteLine($" Deal : {afterDeal - beforeDeal}");
|
||||
TestContext.WriteLine($" Swap A : {afterSwapA - afterDeal}");
|
||||
TestContext.WriteLine($" Ready A (mulligan): {afterReadyA - afterSwapA}");
|
||||
TestContext.WriteLine($" Swap B : {afterSwapB - afterReadyA}");
|
||||
TestContext.WriteLine($" Ready B (mulligan): {afterReadyB - afterSwapB}");
|
||||
TestContext.WriteLine($" spin pre-roll : {afterSpin - afterReadyB} (expected {WireSpin})");
|
||||
TestContext.WriteLine($" all sends/plays : {afterSends - afterSpin}");
|
||||
TestContext.WriteLine($" TOTAL : {log.Count}");
|
||||
TestContext.WriteLine("");
|
||||
|
||||
DumpRange("DEAL", log, beforeDeal, afterDeal);
|
||||
DumpRange("SWAP A (mulligan lottery, seat A)", log, afterDeal, afterSwapA);
|
||||
DumpRange("READY A (mulligan, seat A)", log, afterSwapA, afterReadyA);
|
||||
DumpRange("SWAP B (mulligan lottery, seat B)", log, afterReadyA, afterSwapB);
|
||||
DumpRange("READY B (mulligan, seat B)", log, afterSwapB, afterReadyB);
|
||||
DumpSpinSummary("SPIN PRE-ROLL", log, afterReadyB, afterSpin);
|
||||
// first ~12 of the play phase covers the early turn-start draws for both seats
|
||||
DumpRange("FIRST PLAY-PHASE ROLLS (turn draws + effects)", log, afterSpin,
|
||||
System.Math.Min(afterSpin + 12, afterSends));
|
||||
|
||||
// === STATE-vs-STACK attribution analysis ===
|
||||
AnalyzeAttribution(log, afterSpin);
|
||||
|
||||
Assert.Pass($"probe complete: {log.Count} rolls logged; see TestContext output for attribution analysis");
|
||||
}
|
||||
|
||||
private static void DumpRange(string label, IReadOnlyList<SessionBattleEngine.RollEntry> log, int from, int to)
|
||||
{
|
||||
TestContext.WriteLine($"--- {label} [rolls {from}..{to - 1}] ({to - from} rolls) ---");
|
||||
for (int i = from; i < to; i++)
|
||||
{
|
||||
var e = log[i];
|
||||
TestContext.WriteLine($" #{e.Index} {e.Api}(arg={e.Arg}) | mgrState: self.IsSelfTurn={e.SelfIsSelfTurn} oppo.IsSelfTurn={e.OppoIsSelfTurn} | classify={Classify(e)}");
|
||||
TestContext.WriteLine($" stack: {e.Stack}");
|
||||
}
|
||||
if (to - from == 0) TestContext.WriteLine(" (none)");
|
||||
TestContext.WriteLine("");
|
||||
}
|
||||
|
||||
private static void DumpSpinSummary(string label, IReadOnlyList<SessionBattleEngine.RollEntry> log, int from, int to)
|
||||
{
|
||||
TestContext.WriteLine($"--- {label} [rolls {from}..{to - 1}] ({to - from} rolls) ---");
|
||||
if (to - from > 0)
|
||||
{
|
||||
var first = log[from];
|
||||
TestContext.WriteLine($" first spin roll #{first.Index}: self.IsSelfTurn={first.SelfIsSelfTurn} oppo.IsSelfTurn={first.OppoIsSelfTurn}");
|
||||
TestContext.WriteLine($" stack: {first.Stack}");
|
||||
bool allStateIdentical = log.Skip(from).Take(to - from)
|
||||
.All(e => e.SelfIsSelfTurn == first.SelfIsSelfTurn && e.OppoIsSelfTurn == first.OppoIsSelfTurn);
|
||||
bool allViaStartOperate = log.Skip(from).Take(to - from).All(e => e.Stack.Contains("StartOperate"));
|
||||
TestContext.WriteLine($" all {to - from} spin rolls have identical mgr seat-state? {allStateIdentical}");
|
||||
TestContext.WriteLine($" all {to - from} spin rolls routed via OperateReceive.StartOperate? {allViaStartOperate}");
|
||||
}
|
||||
TestContext.WriteLine("");
|
||||
}
|
||||
|
||||
// Best-effort classification from the STACK (the ground truth of who is rolling).
|
||||
private static string Classify(SessionBattleEngine.RollEntry e)
|
||||
{
|
||||
string s = e.Stack;
|
||||
if (s.Contains("StartOperate")) return "SPIN-PREROLL";
|
||||
if (s.Contains("_LotMulliganCardIndex") || s.Contains("MulliganCtrl")) return "MULLIGAN-LOTTERY";
|
||||
if (s.Contains("LotteryRandomDrawCard") || s.Contains("RandomCardDraw")) return "TURN/EFFECT-DRAW";
|
||||
if (s.Contains("SkillRandomSelectFilter")) return "SKILL-FILTER-DRAW";
|
||||
return "OTHER-EFFECT";
|
||||
}
|
||||
|
||||
private static void AnalyzeAttribution(IReadOnlyList<SessionBattleEngine.RollEntry> log, int playPhaseStart)
|
||||
{
|
||||
TestContext.WriteLine("=== STATE-vs-STACK ATTRIBUTION ANALYSIS ===");
|
||||
|
||||
// 1) Does mgr-state (IsSelfTurn flags) ever change across the whole replay? If both flags are
|
||||
// pinned at setup values (self=true/oppo=false) the entire time, mgr-state CANNOT distinguish
|
||||
// seats — every roll looks identical from mgr state.
|
||||
var distinctStates = log
|
||||
.Select(e => (e.SelfIsSelfTurn, e.OppoIsSelfTurn))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
TestContext.WriteLine($" distinct mgr seat-states observed across ALL {log.Count} rolls: {distinctStates.Count}");
|
||||
foreach (var st in distinctStates)
|
||||
TestContext.WriteLine($" (self.IsSelfTurn={st.Item1}, oppo.IsSelfTurn={st.Item2})");
|
||||
|
||||
// 2) For the mulligan lotteries: seat A's 6 rolls then seat B's 6 rolls happen back-to-back. Are
|
||||
// their mgr-states distinguishable? (They should NOT be — IsSelfTurn isn't toggled during
|
||||
// mulligan; both lotteries run with the same setup-time flags.)
|
||||
var mulliganRolls = log.Where(e => Classify(e) == "MULLIGAN-LOTTERY").ToList();
|
||||
var mulliganStates = mulliganRolls.Select(e => (e.SelfIsSelfTurn, e.OppoIsSelfTurn)).Distinct().Count();
|
||||
TestContext.WriteLine($" mulligan-lottery rolls: {mulliganRolls.Count}; distinct mgr seat-states among them: {mulliganStates}");
|
||||
TestContext.WriteLine($" -> seat attributable from mgr STATE alone? {(mulliganStates >= 2 ? "MAYBE" : "NO (state identical for both seats' lotteries)")}");
|
||||
bool mulliganSeatInStack = mulliganRolls.All(e => e.Stack.Contains("Mulligan") || e.Stack.Contains("_LotMulligan"));
|
||||
TestContext.WriteLine($" -> mulligan rolls carry a MulliganCtrl frame on the stack? {mulliganSeatInStack}");
|
||||
|
||||
// 3) For the play-phase draws: are turn-start draws present at all, and do their mgr-states track
|
||||
// the acting seat (i.e. does IsSelfTurn flip to identify whose turn/draw it is)?
|
||||
var drawRolls = log.Skip(playPhaseStart)
|
||||
.Where(e => Classify(e) is "TURN/EFFECT-DRAW" or "SKILL-FILTER-DRAW")
|
||||
.ToList();
|
||||
TestContext.WriteLine($" play-phase draw/filter rolls: {drawRolls.Count}");
|
||||
if (drawRolls.Count > 0)
|
||||
{
|
||||
var drawStates = drawRolls.Select(e => (e.SelfIsSelfTurn, e.OppoIsSelfTurn)).Distinct().Count();
|
||||
TestContext.WriteLine($" distinct mgr seat-states among draw rolls: {drawStates}");
|
||||
}
|
||||
|
||||
TestContext.WriteLine("");
|
||||
TestContext.WriteLine(" INTERPRETATION:");
|
||||
TestContext.WriteLine(" * If distinct mgr seat-states == 1 for a phase, the StableRandom override CANNOT");
|
||||
TestContext.WriteLine(" attribute that phase's rolls to a seat from mgr state — only the call STACK");
|
||||
TestContext.WriteLine(" (MulliganCtrl._battlePlayer / BattlePlayerBase 'this' / OperateReceive._isPlayer)");
|
||||
TestContext.WriteLine(" names the acting seat.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,13 @@ public class RecoveryOperationCollection : WatchOperationCollection
|
||||
|
||||
public override void PlayHandCardOperation(PlayHandCardReflection networkPlayCardAction, List<int> choiceIdList = null, bool isChoice = false)
|
||||
{
|
||||
List<NetworkBattleReceiver.TargetData> actionDictionary = (_isPlayer ? _receivedData.PlayerTargetDataList : _receivedData.OpponentTargetDataList);
|
||||
BattlePlayerBase battlePlayer = _networkBattleMgr.GetBattlePlayer(_isPlayer);
|
||||
CommonPlayHandCardOperation(networkPlayCardAction, battlePlayer, _isPlayer, actionDictionary, choiceIdList, isChoice);
|
||||
// Route ALL recovery hand-plays through PlayAction (the type:31 PLAY_HAND_SELECT path).
|
||||
// PlayAction resolves targets from the receiver's target data and calls PlayActionMove,
|
||||
// which bypasses PlayMove's two-phase user-select guard (the guard that aborts on targeted
|
||||
// spells with SendEcho+return, waiting for a follow-up type:31 frame that never comes in
|
||||
// recovery/shadow mode). PlayAction is the path RecoveryOperationCollection already uses
|
||||
// for type:31; unifying type:30 here makes all spell plays resolve headless.
|
||||
PlaySkillSelectHandCardOperation(networkPlayCardAction, choiceIdList);
|
||||
}
|
||||
|
||||
public override void PlaySkillSelectHandCardOperation(PlayHandCardReflection networkPlayCardAction, List<int> choiceIds = null)
|
||||
|
||||
@@ -18,4 +18,10 @@
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Test access to shim internals (HeadlessHandViewStub, etc.) so headless-shim regression tests
|
||||
can pin the no-op contracts directly without an end-to-end battle setup. -->
|
||||
<InternalsVisibleTo Include="SVSim.BattleEngine.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// the abstract RearrangeHand is never reached). Nothing here touches game state.
|
||||
|
||||
using UnityEngine;
|
||||
using Wizard.Battle.View.Vfx;
|
||||
|
||||
namespace Wizard.Battle.View
|
||||
{
|
||||
@@ -24,5 +25,24 @@ namespace Wizard.Battle.View
|
||||
protected override void RearrangeHand(float rearrangeTime, bool isNewReplayMoveTurn = false) { }
|
||||
|
||||
protected override HandControl CreateHandControl(GameObject handGameObject, BattleCamera battleCamera) => null;
|
||||
|
||||
// HEADLESS-FIX: with CreateHandControl returning null, the base implementations of
|
||||
// HandUnfocus/HandFocus/FocusRearrangeHandHand (HandViewBase.cs:124/133/142) NRE on
|
||||
// `_handControl.SetHandState(...)`. These are PURE PRESENTATION methods — they ease the hand
|
||||
// cards in/out visually as a side effect of leader healing, spell selection, etc. — with no
|
||||
// game-state implications, so the safe headless behavior is a no-op returning NullVfx.
|
||||
//
|
||||
// Live regression: bid 799755786270 (2026-06-07). A follower with a `when_spell_play` Heal
|
||||
// trigger fired on its leader for 0 (the trigger fires regardless of heal amount, and even a
|
||||
// 0-heal still drives `ApplyHealing` → `CreatePullHandInVfx` → `HandView.HandUnfocus()`
|
||||
// unconditionally per ClassBattleCardBase.cs:234/239). Stack:
|
||||
// Skill_heal.Start → ClassBattleCardBase.ApplyHealing → CreatePullHandInVfx
|
||||
// → HandViewBase.HandUnfocus → NRE on null _handControl.
|
||||
// Same pattern as the metamorphose-NRE shim fix (ViewUiTouchStubs.cs's
|
||||
// BattleCardView.GameObject lazy non-null): production Unity touches that the headless
|
||||
// engine needs to no-op rather than throw.
|
||||
public override VfxBase HandUnfocus() => NullVfx.GetInstance();
|
||||
public override VfxBase HandFocus() => NullVfx.GetInstance();
|
||||
public override VfxBase FocusRearrangeHandHand() => NullVfx.GetInstance();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,23 @@ namespace Wizard.Battle.View
|
||||
new InPlayCardFrameEffectControl(null, null, null);
|
||||
|
||||
// AttackTargetSelectInfo provided by Generated/BattleCardView_AttackTargetSelectInfo.g.cs
|
||||
public virtual UnityEngine.GameObject GameObject { get; protected set; }
|
||||
//
|
||||
// HEADLESS-FIX: lazily non-null GameObject so unguarded Unity touches on the IsRecovery
|
||||
// path resolve as no-ops instead of NRE-ing on the shim's null default. Matches the
|
||||
// existing Component.gameObject lazy pattern (UnityShim.cs:94). The IsRecovery card-create
|
||||
// delegate (NetworkBattleManagerBase.cs:379) passes null for cardGameObject, which left
|
||||
// BattleCardView.GameObject null and caused Skill_metamorphose.cs:147 (the in-play
|
||||
// metamorphose branch — Petrification etc.) to NRE on
|
||||
// `metamorphosedCard.BattleCardView.GameObject.transform.rotation = Quaternion.identity`,
|
||||
// a purely cosmetic transform reset; making it a no-op preserves the surrounding state
|
||||
// mutations (ReplaceInPlay, SetUpInplay, FlagCardAsDestroyedBySkill, RemoveFromInPlay).
|
||||
// Live regression: bid 283192092460, A's Petrification on B's in-play card idx 1.
|
||||
private UnityEngine.GameObject _gameObject;
|
||||
public virtual UnityEngine.GameObject GameObject
|
||||
{
|
||||
get => _gameObject ??= new UnityEngine.GameObject();
|
||||
protected set => _gameObject = value;
|
||||
}
|
||||
public HandCardFrameEffectControl HandFrameEffect { get; private set; }
|
||||
public static HandParameter.IconLayout GetCurrentIconLayout() => default!;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,13 @@ internal static class BattleFrameDefaults
|
||||
public const string PlayerRank = "10";
|
||||
public const string PlayerBattlePoint = "6270";
|
||||
|
||||
// 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;
|
||||
// Ready-frame spin. Prod shipped 243 (obfuscation base — the spin-rng audit proved ~99% of the
|
||||
// magnitude is non-gameplay). Our node is authority for BOTH clients; they each crank this on
|
||||
// their own shared _stableRandom, but the shadow engine ingests BOTH sides' Ready frames on ONE
|
||||
// stream — so a non-zero value double-cranks the shadow (243×2 = 486 vs each client's 243),
|
||||
// desynchronizing every subsequent StableRandom draw. Zero eliminates the offset; both clients
|
||||
// and the shadow all start at stream position 0.
|
||||
public const int ReadySpin = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's
|
||||
|
||||
@@ -153,7 +153,7 @@ public sealed record MsgEnvelope(
|
||||
return new MsgEnvelope(uri, viewerId, uuid, bid, retryAttempt, cat, pubSeq, playSeq, new RawBody(bodyDict));
|
||||
}
|
||||
|
||||
private static object? ToObject(JsonElement el) => el.ValueKind switch
|
||||
internal static object? ToObject(JsonElement el) => el.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => el.GetString(),
|
||||
// Extracted to a helper because writing the conditional inline as
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
using SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
@@ -35,6 +37,21 @@ public sealed class BattleSession
|
||||
/// never retried, never fatal.</summary>
|
||||
private bool _engineSetupAttempted;
|
||||
|
||||
/// <summary>Guards: server-generated Deal is fed to the shadow engine exactly once (the first
|
||||
/// occurrence from either LoadedHandler invocation). Deal + Ready are server-generated frames the
|
||||
/// engine needs to drive the mulligan: Deal → StartDeal (cards deck→hand for the player seat,
|
||||
/// _firstDrawList for the opponent), Ready → CompleteMulligan → EnemyChangeCardVfx → opponent
|
||||
/// DrawFirstMulliganCard. Without them the engine's hand stays empty and every play throws
|
||||
/// "Target card was not found in hand cards".</summary>
|
||||
private bool _engineDealFed;
|
||||
|
||||
/// <summary>Guards: server-generated Ready is fed to the shadow engine exactly once (the first
|
||||
/// Ready addressed to participant A). Fed as isPlayerSeat=false so the recovery path's
|
||||
/// OperateMulligan enters the OperateOppoMulligan branch — the only branch that invokes
|
||||
/// ReceiveOpponentMulligan → EnemyChangeCardVfx → DrawFirstMulliganCard. The player's mulligan
|
||||
/// was already processed during the Swap feed.</summary>
|
||||
private bool _engineReadyFed;
|
||||
|
||||
/// <summary>True once this session has acquired the process-wide <see cref="Engine.EngineSessionGate"/>
|
||||
/// (and is therefore the single active engine owner). Drives the matching <c>Release</c> at battle
|
||||
/// end so the next session can take the engine.</summary>
|
||||
@@ -241,13 +258,89 @@ public sealed class BattleSession
|
||||
}
|
||||
|
||||
if (Handlers.TryGetValue(env.Uri, out var handler))
|
||||
return handler.Handle(BuildContext(from, env));
|
||||
{
|
||||
var routes = handler.Handle(BuildContext(from, env));
|
||||
try { ShadowFeedServerFrames(routes); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "BattleSession {Bid}: shadow engine error feeding server frames (ignored)", BattleId);
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in lifecycle={Lifecycle} from vid={Vid}",
|
||||
BattleId, env.Uri, Lifecycle, from.ViewerId);
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
|
||||
/// <summary>Feed server-generated mulligan frames (Deal, Swap response, Ready) into the shadow
|
||||
/// engine. These frames are produced by LoadedHandler/SwapHandler and dispatched only to clients
|
||||
/// — they never enter <see cref="ShadowIngest"/> because they're not client-sent. But the engine
|
||||
/// needs them to drive the mulligan: Deal seats the hand, Ready completes the opponent's hand.
|
||||
/// The test harness (<c>NodeNativeBattleHarness</c>) feeds these directly; this method is the
|
||||
/// live-session equivalent.</summary>
|
||||
private void ShadowFeedServerFrames(IReadOnlyList<DispatchRoute> routes)
|
||||
{
|
||||
if (!_engine.IsReady) return;
|
||||
|
||||
foreach (var (target, frame, _) in routes)
|
||||
{
|
||||
switch (frame.Uri)
|
||||
{
|
||||
case NetworkBattleUri.Deal when !_engineDealFed:
|
||||
_engineDealFed = true;
|
||||
_log.LogWarning("BattleSession {Bid}: DEAL DIAG BEFORE: {Diag}",
|
||||
BattleId, _engine.DiagnoseDealState());
|
||||
ShadowFeed(frame, isPlayerSeat: true, "Deal");
|
||||
_log.LogWarning("BattleSession {Bid}: DEAL DIAG AFTER: {Diag}",
|
||||
BattleId, _engine.DiagnoseDealState());
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Swap:
|
||||
// The Swap RESPONSE (server-authored, carries post-mulligan self hand as
|
||||
// pos→idx) must go to the engine for the correct seat. The client-sent Swap
|
||||
// ({idxList}) also enters ShadowIngest but is harmless — its selfIdxList
|
||||
// parses to null (no "self" key) so FirstMulliganOperation no-ops.
|
||||
bool swapIsPlayer = ReferenceEquals(target, A);
|
||||
ShadowFeed(frame, swapIsPlayer, $"SwapResponse({(swapIsPlayer ? "A" : "B")})");
|
||||
break;
|
||||
|
||||
case NetworkBattleUri.Ready when !_engineReadyFed && ReferenceEquals(target, A):
|
||||
_engineReadyFed = true;
|
||||
// Feed A's Ready (carries A's idxChangeSeed → receiver seeds _selfXorShiftRandom).
|
||||
ShadowFeed(frame, isPlayerSeat: false, "Ready");
|
||||
// Seed B's XorShift separately — A's Ready doesn't carry B's seed.
|
||||
_engine.SeedOppoIdxChange(BattleSeeds.IdxChange(_state.MasterSeed, B.ViewerId));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShadowFeed(MsgEnvelope frame, bool isPlayerSeat, string label)
|
||||
{
|
||||
var engineFrame = frame.Body is RawBody ? frame : frame with { Body = ToRawBody(frame.Body) };
|
||||
var r = _engine.Receive(engineFrame, isPlayerSeat);
|
||||
if (r.Diverged)
|
||||
_log.LogWarning("BattleSession {Bid}: shadow engine diverged on {Label} feed: {Reason}",
|
||||
BattleId, label, r.RejectReason);
|
||||
if (frame.Uri is NetworkBattleUri.Deal or NetworkBattleUri.Swap or NetworkBattleUri.Ready)
|
||||
LogEngineHandState(frame.Uri, $"ShadowFeed({label})");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions _bodyJsonOptions = Wire.WireJsonOptions.CamelCase;
|
||||
|
||||
/// <summary>Convert a typed body record (DealBody, SwapResponseBody, ReadyBody, etc.) to the
|
||||
/// <see cref="RawBody"/> the engine receiver expects. Serialize → JsonElement → ToObject (the
|
||||
/// same deep-conversion MsgEnvelope.FromJson uses for incoming wire frames).</summary>
|
||||
private static RawBody ToRawBody(IMsgBody? body)
|
||||
{
|
||||
if (body is null) return new RawBody(new Dictionary<string, object?>());
|
||||
var el = JsonSerializer.SerializeToElement(body, body.GetType(), _bodyJsonOptions);
|
||||
var dict = el.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => MsgEnvelope.ToObject(p.Value));
|
||||
return new RawBody(dict);
|
||||
}
|
||||
|
||||
/// <summary>Seat the shadow engine once, from the master seed + both deterministically-shuffled
|
||||
/// decks the node already computed (F-N-5). Attempted a single time; if the host can't seat the
|
||||
/// engine headless, it stays not-ready and the shadow no-ops for the rest of the battle.</summary>
|
||||
@@ -268,7 +361,16 @@ public sealed class BattleSession
|
||||
return;
|
||||
}
|
||||
_engineOwned = true;
|
||||
_engine.Setup(_state.MasterSeed,
|
||||
// Seed the engine's StableRandom with BattleSeeds.Stable(MasterSeed) — the SAME value the
|
||||
// Matched frame ships to both clients (InitBattleHandler.cs:28). The clients seed their
|
||||
// System.Random with Matched.seed (BattleManagerBase.cs:721), so the engine's stream must
|
||||
// share that derivation to track. MasterSeed itself is a root only — every wire-facing seed
|
||||
// (Stable, IdxChange, DeckShuffle) is a BattleSeeds.Derive(...) of it; the engine never
|
||||
// consumes the root directly. Live regression: bid 654473755566 had MasterSeed=1184631275
|
||||
// and Stable=1543475792 (the Matched.seed); seeding the engine with the raw root made every
|
||||
// turn-1+ draw pick a different deck position than the clients, so the opponent's first
|
||||
// non-mulligan play addressed a card the engine never drew → HandCardToField threw.
|
||||
_engine.Setup(BattleSeeds.Stable(_state.MasterSeed),
|
||||
_state.GetShuffledDeck(A), _state.GetShuffledDeck(B),
|
||||
(int)A.Context.ClassId, (int)B.Context.ClassId);
|
||||
}
|
||||
@@ -279,8 +381,21 @@ public sealed class BattleSession
|
||||
bool isPlayerSeat = ReferenceEquals(from, A);
|
||||
var r = _engine.Receive(env, isPlayerSeat);
|
||||
if (r.Diverged)
|
||||
_log.LogInformation("BattleSession {Bid}: shadow engine diverged on {Uri}: {Reason}",
|
||||
_log.LogWarning("BattleSession {Bid}: shadow engine diverged on {Uri}: {Reason}",
|
||||
BattleId, env.Uri, r.RejectReason);
|
||||
if (env.Uri is NetworkBattleUri.Swap or NetworkBattleUri.TurnStart or NetworkBattleUri.PlayActions)
|
||||
LogEngineHandState(env.Uri, $"ShadowIngest(seat={(isPlayerSeat ? "A" : "B")})");
|
||||
}
|
||||
|
||||
private void LogEngineHandState(NetworkBattleUri uri, string label)
|
||||
{
|
||||
if (!_engine.IsReady) return;
|
||||
var aIdxs = string.Join(",", Enumerable.Range(0, _engine.HandCount(true))
|
||||
.Select(i => _engine.HandCardIndex(true, i)));
|
||||
var bIdxs = string.Join(",", Enumerable.Range(0, _engine.HandCount(false))
|
||||
.Select(i => _engine.HandCardIndex(false, i)));
|
||||
_log.LogInformation("BattleSession {Bid}: engine hand after {Uri} {Label}: A=[{AHand}] B=[{BHand}]",
|
||||
BattleId, uri, label, aIdxs, bIdxs);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -66,15 +66,43 @@ internal sealed class SessionBattleEngine
|
||||
public void Setup(int masterSeed,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
|
||||
int seatAClass = 1, int seatBClass = 2)
|
||||
=> SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null);
|
||||
|
||||
/// <summary>TEST/DEBUG SEAM (Phase 4 Option-A viability PROBE — NOT a production fix). Identical to
|
||||
/// <see cref="Setup(int, IReadOnlyList{long}, IReadOnlyList{long}, int, int)"/> but installs a logging
|
||||
/// RNG source that, on EVERY <c>StableRandom</c>/<c>StableRandomDouble</c> roll, records a roll entry
|
||||
/// (call index, API, the seat signals readable from mgr state at roll time, and the live call stack).
|
||||
/// Lets a test answer: at roll time, is the ACTING SEAT determinable from mgr state alone, or only from
|
||||
/// the stack? No production path calls this.</summary>
|
||||
internal IReadOnlyList<RollEntry> DebugSetupWithRollLog(int masterSeed,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
|
||||
int seatAClass = 1, int seatBClass = 2)
|
||||
{
|
||||
var log = new List<RollEntry>();
|
||||
// The logger needs the mgr to read seat signals at roll time; the mgr is built inside Setup, so the
|
||||
// logger reads it lazily via a closure populated right after construction.
|
||||
HeadlessNetworkBattleMgr[] mgrBox = { null! };
|
||||
var rng = new RollLoggingRandomSource(new SeededRandomSource(masterSeed), log, () => mgrBox[0]);
|
||||
SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng, mgrBox);
|
||||
return log;
|
||||
}
|
||||
|
||||
private void SetupInternal(int masterSeed,
|
||||
IReadOnlyList<long> seatADeck, IReadOnlyList<long> seatBDeck,
|
||||
int seatAClass, int seatBClass,
|
||||
IRandomSource? rng, HeadlessNetworkBattleMgr[]? mgrBox = null)
|
||||
{
|
||||
// Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master,
|
||||
// GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds
|
||||
// here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A).
|
||||
EngineGlobalInit.EnsureInitialized();
|
||||
|
||||
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — the stream is born aligned
|
||||
// with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway").
|
||||
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed));
|
||||
// rng defaults to SeededRandomSource(masterSeed) inside the mgr — masterSeed here is the
|
||||
// engine's StableRandom seed (parameter name preserved for API compatibility; callers pass
|
||||
// BattleSeeds.Stable(rootMasterSeed) so the stream is born aligned with the seed the node
|
||||
// ships to both clients in Matched.seed). F-N-5; O-N-2 "bit-aligned anyway".
|
||||
var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed), rng);
|
||||
if (mgrBox is not null) mgrBox[0] = mgr; // publish for the test roll-logger closure (DebugSetupWithRollLog)
|
||||
// Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the
|
||||
// receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX
|
||||
// waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+),
|
||||
@@ -93,15 +121,20 @@ internal sealed class SessionBattleEngine
|
||||
player.IsSelfTurn = true;
|
||||
enemy.IsSelfTurn = false;
|
||||
|
||||
// Seat the evolve points + evolve-wait-turn counters exactly as the real match-load's
|
||||
// SetupInitialGameState -> SetupEvolCount does (BattleManagerBase.cs:1115/1132). The headless
|
||||
// Setup builds the seats by hand and never runs SetupInitialGameState, so without this both seats'
|
||||
// CurrentEpCount/EvolveWaitTurnCount stay at their field defaults (0/0) and CanEvolution always
|
||||
// fails (CurrentEpCount - GetEp() < 0). doesPlayerGoFirst == false here: seat A (BattlePlayer) is
|
||||
// the SECOND player (IsFirst defaults false; seat A's turn-1 draws 2), so it gets SECOND_PLAYER_EP
|
||||
// (3) + EvolveWaitTurnCount 4, and seat B (BattleEnemy, first) gets FIRST_PLAYER_EP (2) +
|
||||
// EvolveWaitTurnCount 5. TurnEvolveControl (run on each TurnStart receive) counts the wait down.
|
||||
mgr.SetupEvolCount(doesPlayerGoFirst: false);
|
||||
// Participant A always goes first (LoadedHandler gives A TurnState.First). The engine's
|
||||
// BattlePlayer = isPlayer=true = seat A, so doesPlayerGoFirst must be true. This controls:
|
||||
// (1) SetupEvolCount: first player gets FIRST_PLAYER_EP (2) + wait 5,
|
||||
// second player gets SECOND_PLAYER_EP (3) + wait 4
|
||||
// (2) IsFirst → BattlePlayer.IsGameFirst / BattleEnemy.IsGameFirst → turn-1 draw count:
|
||||
// first player draws 1, second draws 2 (BattlePlayerBase.TurnStartDrawCard)
|
||||
mgr.IsFirst = true;
|
||||
mgr.SetupEvolCount(doesPlayerGoFirst: true);
|
||||
|
||||
// The real match-load's SetupInitialGameState(areCardsRandomlyDrawn:true) sets this flag
|
||||
// (BattleManagerBase.cs:1110), routing LotteryRandomDrawCard through seeded StableRandom
|
||||
// instead of top-of-deck. Without it the shadow draws DeckCardList[0] every time while
|
||||
// clients draw seeded-random — desynchronizing the hand and every downstream field.
|
||||
BattleManagerBase.IsRandomDraw = true;
|
||||
|
||||
InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays
|
||||
InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer
|
||||
@@ -136,6 +169,7 @@ internal sealed class SessionBattleEngine
|
||||
|
||||
var dict = ToEngineDict((env.Body as RawBody)?.Entries);
|
||||
TranslateTargetOwners(dict, isPlayerSeat);
|
||||
TranslateChoiceKeyAction(dict);
|
||||
var uri = MapUri(env.Uri);
|
||||
|
||||
try
|
||||
@@ -210,6 +244,71 @@ internal sealed class SessionBattleEngine
|
||||
private const string TargetListKey = "targetList";
|
||||
private const string VidKey = "vid";
|
||||
private const string IsSelfKey = "isSelf";
|
||||
private const string KeyActionKey = "keyAction";
|
||||
private const string SelectCardKey = "selectCard";
|
||||
private const string CardIdKey = "cardId";
|
||||
|
||||
// --- live Choice-keyAction shape translation (live PvP ingest fidelity) ------------------------
|
||||
//
|
||||
// THE GAP this closes: a Choice play's wire keyAction entry on the SENDER's send is the wrapped
|
||||
// shape `{type:1, cardId:<choiceCardId>, selectCard:{cardId:[<chosenId>...], open:0|1}}` (verified
|
||||
// in client-send captures, e.g. data_dumps/captures/battle_test/cl1/battle-traffic.ndjson live
|
||||
// Resonance play). The engine's receive parser (NetworkBattleReceiver.cs:1202) reads the
|
||||
// `selectCard` value through `ConvertToListInt`, which does `value as List<object>` — a Dictionary
|
||||
// value casts to null and the inner `foreach (... in null)` throws NRE. The whole
|
||||
// ConvertReceiveDataToMakeData is wrapped in a swallow-catch (NetworkBattleReceiver.cs:1255-1260)
|
||||
// that logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and returns false.
|
||||
// SessionBattleEngine.Receive calls ReceivedMessage with checkBreakData:false, so the false isn't
|
||||
// surfaced; the engine continues with `choiceIdList=[]`, the choice never resolves, and the played
|
||||
// card never moves from hand to board. Then any LATER frame that addresses the un-resolved card
|
||||
// by Index sees a stale hand entry — silently for a turn or two, until a TARGETED play looks for
|
||||
// it on the board (where it should be per wire) and gets `null` from LookForActionDataToTargetCard
|
||||
// → ActionProcessor.PlayCard:407 NRE on `selectedCard.SelfBattlePlayer`.
|
||||
//
|
||||
// OPPONENT-FACING relay shape is different: the node strips selectCard entirely from the opponent
|
||||
// broadcast (verified: cl2 receives `keyAction:[{type:1, cardId:127011010}]`), so the opponent
|
||||
// never needs this transform. Only the shadow engine — which ingests the SENDER's raw send — does.
|
||||
//
|
||||
// The fix: walk keyAction on the ENGINE's own dict copy (TranslateTargetOwners' pattern) and
|
||||
// unwrap selectCard. `{cardId:[121011010], open:0}` → `[121011010]`. The `open` flag (was this
|
||||
// choice revealed to the opponent) is irrelevant to the engine's resolution. The flat-list shape
|
||||
// is what `ConvertToListInt` consumes successfully, AND what the existing test harness
|
||||
// (NodeNativeBattleHarness.ChoicePlayBody) already supplies — that test passes, proving the rest
|
||||
// of the Choice resolution path works given the right shape. Idempotent: an already-flat list
|
||||
// (no wrapping dict) is left alone, so a future relay frame that happens to carry the flat form
|
||||
// also resolves directly.
|
||||
//
|
||||
// Live regression: bid 131549100204, B's Resonance (127011010) play of idx 20 at error.txt:1642.
|
||||
// Without the unwrap, idx 20 stays in B's hand; later A's 6-cost bounce targets B's "board" idx 20,
|
||||
// engine can't find it on the board, ActionProcessor.PlayCard NRE's at the foreach over a list
|
||||
// containing a null target.
|
||||
private static void TranslateChoiceKeyAction(Dictionary<string, object> dict)
|
||||
{
|
||||
if (!dict.TryGetValue(KeyActionKey, out var raw) || raw is not List<object> entries)
|
||||
return;
|
||||
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e is not Dictionary<string, object> entry) continue;
|
||||
if (!entry.TryGetValue(SelectCardKey, out var sel)) continue;
|
||||
// Already-flat (a List): no transform needed. Idempotent guard.
|
||||
if (sel is List<object>) continue;
|
||||
// Wrapped (a Dict): unwrap to the inner cardId list.
|
||||
if (sel is Dictionary<string, object> wrap
|
||||
&& wrap.TryGetValue(CardIdKey, out var inner)
|
||||
&& inner is List<object> flat)
|
||||
{
|
||||
entry[SelectCardKey] = flat;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unrecognized shape — drop the key so the parse doesn't NRE; the play will resolve
|
||||
// with an empty choice list, and the divergence (if any) will surface downstream
|
||||
// rather than crash the receiver.
|
||||
entry.Remove(SelectCardKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The decoded wire value may be a boxed long/int/bool depending on the codec; normalize to int.
|
||||
private static int ToInt(object v) => v switch
|
||||
@@ -457,6 +556,102 @@ internal sealed class SessionBattleEngine
|
||||
return card.Cost;
|
||||
}
|
||||
|
||||
// === TEST/DEBUG SEAMS (Phase 4 root-cause verification — NOT a production fix) =================
|
||||
// These exist solely to PROVE the post-mulligan reshuffle root cause from a test. They read/poke
|
||||
// the engine's XorShift idx-change RNG, which the live recovery path leaves null/inactive (seed -1).
|
||||
// No production code path calls them. Remove (or fold into the real seeding) when the fix lands.
|
||||
|
||||
/// <summary>TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active? Mirrors the gate the
|
||||
/// post-mulligan deck reshuffle/re-index checks (<c>BattleMgr.XorShiftRandom(true) != null &&
|
||||
/// .IsActive</c>, BattlePlayerBase.cs:3049/3073). Under the live recovery setup
|
||||
/// (<c>CreateXorShift(-1,-1)</c> via NullRecoveryManager.IdxChangeSeed == -1) this is FALSE, so the
|
||||
/// engine SKIPS the reshuffle the real clients performed.</summary>
|
||||
internal bool SelfXorShiftActive => (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false;
|
||||
|
||||
/// <summary>TEST/DEBUG: same as <see cref="SelfXorShiftActive"/> for the OPPONENT seat.</summary>
|
||||
internal bool OppoXorShiftActive => (_mgr?.XorShiftRandom(isSelf: false)?.IsActive) ?? false;
|
||||
|
||||
/// <summary>DIAGNOSTIC: check if OnReceiveDeal is wired and report deck/hand counts.</summary>
|
||||
internal string DiagnoseDealState()
|
||||
{
|
||||
if (_mgr is null) return "mgr=null";
|
||||
var or = _mgr.OperateReceive;
|
||||
bool dealWired = or.OnReceiveDeal != null;
|
||||
var p = _mgr.GetBattlePlayer(true);
|
||||
var e = _mgr.GetBattlePlayer(false);
|
||||
return $"OnReceiveDeal={(dealWired ? "wired" : "NULL")}, " +
|
||||
$"playerDeck={p.DeckCardList.Count}, playerHand={p.HandCardList.Count}, " +
|
||||
$"enemyDeck={e.DeckCardList.Count}, enemyHand={e.HandCardList.Count}";
|
||||
}
|
||||
|
||||
/// <summary>Seed the opponent seat's XorShift for post-mulligan deck reshuffle. The Ready frame's
|
||||
/// <c>idxChangeSeed</c> seeds the self seat (BattlePlayer/A) automatically via the receiver. The
|
||||
/// opponent seat (BattleEnemy/B) needs its seed injected separately because the Ready frame sent
|
||||
/// to A doesn't carry B's seed. Called from <see cref="BattleSession.ShadowFeedServerFrames"/>
|
||||
/// after feeding the Ready.</summary>
|
||||
internal void SeedOppoIdxChange(int oppoSeed)
|
||||
{
|
||||
_mgr?.CreateXorShift(-1, oppoSeed);
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: inject BOTH per-seat idxChange seeds at once (the verification seam the
|
||||
/// PostMulliganReshuffleRootCauseTests use). Production code uses the Ready frame for the self
|
||||
/// seed + <see cref="SeedOppoIdxChange"/> for the opponent seed.</summary>
|
||||
internal void DebugSeedIdxChange(int selfSeed, int oppoSeed)
|
||||
{
|
||||
if (_mgr is null) throw new InvalidOperationException("DebugSeedIdxChange before Setup.");
|
||||
_mgr.CreateXorShift(selfSeed, oppoSeed);
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: override the engine's process-global <c>BattleManagerBase.IsRandomDraw</c>
|
||||
/// flag. Production Setup now sets this true (matching the real match-load's
|
||||
/// <c>SetupInitialGameState(areCardsRandomlyDrawn:true)</c>). This seam exists so tests can
|
||||
/// force it false to reproduce the old top-of-deck bug. Static field → set per run under
|
||||
/// [NonParallelizable].</summary>
|
||||
internal void DebugSetRandomDraw(bool value) => BattleManagerBase.IsRandomDraw = value;
|
||||
|
||||
/// <summary>TEST/DEBUG (Phase 4 draw-recompute hypothesis): advance the SHARED <c>_stableRandom</c>
|
||||
/// stream by <paramref name="n"/> draws, exactly as <c>OperateReceive.StartOperate</c> does on a
|
||||
/// received frame carrying <c>spin=n</c> (OperateReceive.cs:80-83 loops <c>StableRandomDouble()</c>
|
||||
/// n times). The live shadow never ingests the Ready frame that carries the wire spin, so its stream
|
||||
/// is offset; this applies the pre-roll at the same point the real client would.</summary>
|
||||
internal void DebugSpinPreroll(int n)
|
||||
{
|
||||
if (_mgr is null) throw new InvalidOperationException("DebugSpinPreroll before Setup.");
|
||||
for (int i = 0; i < n; i++) _mgr.StableRandomDouble();
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: consume one value from the shared <c>_stableRandom</c> stream and return it.
|
||||
/// Lets a regression test assert engine seed alignment against the wire — the very first
|
||||
/// <c>StableRandom.NextDouble()</c> the engine produces must equal the first <c>NextDouble()</c> of a
|
||||
/// fresh <c>System.Random(BattleSeeds.Stable(masterSeed))</c>, since clients seed
|
||||
/// <c>_stableRandom = new System.Random(Matched.seed)</c> with the SAME value
|
||||
/// (BattleManagerBase.cs:721; Matched.seed == BattleSeeds.Stable(masterSeed),
|
||||
/// InitBattleHandler.cs:28).</summary>
|
||||
internal double DebugStableRandomDouble()
|
||||
{
|
||||
if (_mgr is null) throw new InvalidOperationException("DebugStableRandomDouble before Setup.");
|
||||
return _mgr.StableRandomDouble();
|
||||
}
|
||||
|
||||
/// <summary>TEST/DEBUG: read the per-seat <c>cardTotalNum</c> counter that drives auto-assigned
|
||||
/// Index for skill-generated tokens (BattleManagerBase.SetupCardIndex uses this when
|
||||
/// <c>addIndex == -1</c>). After Setup it must equal <c>deck.Count + 1</c> on both seats (matches
|
||||
/// the real client's <c>SBattleLoad.InitPlayer</c> tail, SBattleLoad.cs:1292), so the FIRST
|
||||
/// generated token gets Index 41 — clear of deck-loaded indices 1..40 — and matches the wire
|
||||
/// <c>add.idx</c>. A stale value of 0 causes tokens to take Index 0, 1, ... and collide.</summary>
|
||||
internal int DebugCardTotalNum(bool playerSeat) =>
|
||||
_mgr is null ? -1 : _mgr.GetBattlePlayer(playerSeat).cardTotalNum;
|
||||
|
||||
/// <summary>TEST/DEBUG: the engine's running <c>StableRandom</c>/<c>StableRandomDouble</c> call count
|
||||
/// (private <c>BattleManagerBase.stableRandomCount</c>), so a divergence dump can report how far the
|
||||
/// shared stream has advanced at the moment of a mismatch.</summary>
|
||||
internal int DebugStableRandomCount =>
|
||||
_mgr is null ? -1
|
||||
: (int)(typeof(BattleManagerBase)
|
||||
.GetField("stableRandomCount", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(_mgr) ?? -1);
|
||||
|
||||
private engine::BattlePlayerBase Seat(bool playerSeat) =>
|
||||
(_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);
|
||||
|
||||
@@ -598,7 +793,18 @@ internal sealed class SessionBattleEngine
|
||||
|
||||
/// <summary>Seat one side's full deck in order (idx == list position + 1). Each card is created
|
||||
/// through the engine's own null-view seam and pushed via AddToDeck — the SeedDeck primitive the
|
||||
/// test harness proved (HeadlessFixture.SeedDeck).</summary>
|
||||
/// test harness proved (HeadlessFixture.SeedDeck).
|
||||
/// <para>Mirrors the real client's <c>SBattleLoad.InitPlayer</c>/<c>InitEnemy</c> tail: after
|
||||
/// loading the 40-card deck at indices 1..40, set <c>cardTotalNum = deck.Count + 1</c> so the
|
||||
/// next skill-generated token gets Index 41 (matches the wire's <c>add.idx</c>). Without this,
|
||||
/// <c>cardTotalNum</c> stays at the property default (0) and the auto-assign path
|
||||
/// (<c>SetupCardIndex(_, -1)</c> in BattleManagerBase.cs:1770) hands tokens Index 0, 1, ...,
|
||||
/// which COLLIDES with deck-loaded cards' Index 1..40. The collision is silent until something
|
||||
/// plays the deck card with the colliding Index (e.g. Hoverboarder at deck idx 1 with a token
|
||||
/// at engine Index 1): <c>GetBattleCardIdx</c>'s <c>SingleOrDefault</c> finds two matches and
|
||||
/// throws "Sequence contains more than one matching element". Also pin
|
||||
/// <c>BattleStartDeckCardList</c> like the real client, so any skill that reads the starting
|
||||
/// deck (e.g. tribe filters) sees the seeded deck instead of an empty list.</para></summary>
|
||||
private static void SeedDeck(BattleManagerBase mgr, IReadOnlyList<long> deck, bool isPlayer)
|
||||
{
|
||||
BattlePlayerBase owner = mgr.GetBattlePlayer(isPlayer);
|
||||
@@ -607,6 +813,8 @@ internal sealed class SessionBattleEngine
|
||||
var card = CreateHeadlessCard(mgr, (int)deck[i], index: i + 1, isPlayer);
|
||||
owner.AddToDeck(card);
|
||||
}
|
||||
owner.cardTotalNum = deck.Count + 1;
|
||||
owner.BattleStartDeckCardList = new List<BattleCardBase>(owner.DeckCardList);
|
||||
}
|
||||
|
||||
private static readonly MethodInfo CreateCardWithoutResources =
|
||||
@@ -697,4 +905,79 @@ internal sealed class SessionBattleEngine
|
||||
t = t.BaseType;
|
||||
}
|
||||
}
|
||||
|
||||
// === TEST/DEBUG: per-roll attribution probe (Phase 4 Option-A viability) =======================
|
||||
// Captures, at the EXACT moment of each StableRandom*/StableRandomDouble roll, the seat signals the
|
||||
// mgr can read from its own state, plus the live call stack. The decisive question: can the acting
|
||||
// seat be attributed from mgr STATE alone (a router could route on it), or only by reading the STACK?
|
||||
|
||||
/// <summary>One recorded RNG roll. <paramref name="SelfIsSelfTurn"/>/<paramref name="OppoIsSelfTurn"/>
|
||||
/// are the mgr-readable seat-turn flags at roll time; <paramref name="Stack"/> is the trimmed call
|
||||
/// stack (the only place the acting seat is sometimes visible).</summary>
|
||||
internal sealed record RollEntry(
|
||||
int Index, string Api, int Arg,
|
||||
bool SelfIsSelfTurn, bool OppoIsSelfTurn,
|
||||
string Stack);
|
||||
|
||||
// A logging IRandomSource: delegates to the real seeded source but records each roll. Reads the mgr's
|
||||
// seat-turn flags (the richest seat signal a mgr-level StableRandom override can see — there is no
|
||||
// "current operating seat" field on the mgr) and the call stack at the call site.
|
||||
private sealed class RollLoggingRandomSource : IRandomSource
|
||||
{
|
||||
private readonly IRandomSource _inner;
|
||||
private readonly List<RollEntry> _log;
|
||||
private readonly Func<HeadlessNetworkBattleMgr?> _mgr;
|
||||
private int _i;
|
||||
|
||||
public RollLoggingRandomSource(IRandomSource inner, List<RollEntry> log, Func<HeadlessNetworkBattleMgr?> mgr)
|
||||
{
|
||||
_inner = inner; _log = log; _mgr = mgr;
|
||||
}
|
||||
|
||||
public double NextUnit() { Record("NextUnit", -1); return _inner.NextUnit(); }
|
||||
public int NextSelf(int max) { Record("NextSelf", max); return _inner.NextSelf(max); }
|
||||
|
||||
private void Record(string api, int arg)
|
||||
{
|
||||
bool selfTurn = false, oppoTurn = false;
|
||||
try
|
||||
{
|
||||
var mgr = _mgr();
|
||||
if (mgr is not null)
|
||||
{
|
||||
selfTurn = mgr.GetBattlePlayer(true).IsSelfTurn;
|
||||
oppoTurn = mgr.GetBattlePlayer(false).IsSelfTurn;
|
||||
}
|
||||
}
|
||||
catch { /* read-only probe; never let a state read abort the roll */ }
|
||||
|
||||
string stack = TrimStack(System.Environment.StackTrace);
|
||||
_log.Add(new RollEntry(_i++, api, arg, selfTurn, oppoTurn, stack));
|
||||
}
|
||||
|
||||
// Keep the frames that reveal WHO is rolling (mulligan lottery vs draw vs filter vs spin pre-roll),
|
||||
// dropping the logger's own frames and System.Environment.
|
||||
private static string TrimStack(string raw)
|
||||
{
|
||||
var lines = (raw ?? "").Split('\n')
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0
|
||||
&& !s.Contains("RollLoggingRandomSource")
|
||||
&& !s.Contains("Environment.get_StackTrace")
|
||||
&& !s.Contains("Environment.GetStackTrace"))
|
||||
.Select(Shorten)
|
||||
.Take(8);
|
||||
return string.Join(" <- ", lines);
|
||||
}
|
||||
|
||||
// "at Namespace.Type.Method(args) in file:line N" -> "Type.Method" (keep it scannable).
|
||||
private static string Shorten(string frame)
|
||||
{
|
||||
string s = frame.StartsWith("at ") ? frame.Substring(3) : frame;
|
||||
int paren = s.IndexOf('(');
|
||||
if (paren >= 0) s = s.Substring(0, paren);
|
||||
var parts = s.Split('.');
|
||||
return parts.Length >= 2 ? parts[^2] + "." + parts[^1] : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
@@ -169,7 +170,7 @@ public class HeadlessConductorTests
|
||||
Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "Swap");
|
||||
var ready = harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true);
|
||||
var ready = harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false);
|
||||
Assert.That(ready.Accepted, Is.True, $"Ready rejected: {ready.RejectReason}");
|
||||
|
||||
// After Ready the mulligan is sealed and the main phase is entered, but no turn has been
|
||||
@@ -180,32 +181,29 @@ public class HeadlessConductorTests
|
||||
Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(0), "no turn opened yet after Ready");
|
||||
|
||||
// --- turn 1 (seat A active) -------------------------------------------------------------
|
||||
// Seat A is the engine's player seat and is NOT game-first here, so turn-1 draws TWO cards
|
||||
// (the standard second-player turn-1 draw). PP ramps to 1.
|
||||
// Seat A is game-first (doesPlayerGoFirst: true), so turn-1 draws ONE card. PP ramps to 1.
|
||||
var t1 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true);
|
||||
Assert.That(t1.Accepted, Is.True, $"turn1 TurnStart rejected: {t1.RejectReason}");
|
||||
Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(1), "seat A turn counter");
|
||||
Assert.That(harness.Pp(playerSeat: true), Is.EqualTo(1), "turn 1 ramps seat A max PP to 1");
|
||||
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(5),
|
||||
"turn-1 second-player draw is 2 cards (3 -> 5)");
|
||||
Assert.That(harness.DeckCount(playerSeat: true), Is.EqualTo(25), "seat A deck after draw");
|
||||
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(4),
|
||||
"turn-1 first-player draw is 1 card (3 mulligan + 1 draw)");
|
||||
Assert.That(harness.DeckCount(playerSeat: true), Is.EqualTo(26), "seat A deck after draw");
|
||||
|
||||
// End seat A's turn.
|
||||
var t1End = harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true);
|
||||
Assert.That(t1End.Accepted, Is.True, $"turn1 TurnEnd rejected: {t1End.RejectReason}");
|
||||
|
||||
// --- turn 2 (seat B active) -------------------------------------------------------------
|
||||
// Seat B opens its first turn: PP ramps to 1 and it draws its turn-1 card. (Seat B's deck
|
||||
// started full at 30 because its opening hand is dealt into hidden zones, not its
|
||||
// HandCardList, until reveal — so its first visible draw moves deck 30 -> 29, hand 0 -> 1.)
|
||||
// Seat B is second player (doesPlayerGoFirst: true → enemy goes second). Ready's
|
||||
// isPlayerSeat=false triggers OperateOppoMulligan → DrawFirstMulliganCard, moving 3 dealt
|
||||
// cards from deck to hand. Turn-1 draws 2 (second player draws 2 on turn 1).
|
||||
var t2 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false);
|
||||
Assert.That(t2.Accepted, Is.True, $"turn2 TurnStart rejected: {t2.RejectReason}");
|
||||
Assert.That(harness.Turn(playerSeat: false), Is.EqualTo(1), "seat B turn counter");
|
||||
Assert.That(harness.Pp(playerSeat: false), Is.EqualTo(1), "turn 2 ramps seat B max PP to 1");
|
||||
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(1), "seat B turn-1 draw");
|
||||
// Seat B's opening hand was dealt into hidden zones (not HandCardList), so its deck started at 30;
|
||||
// the single turn-1 draw brings it to 29.
|
||||
Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(29), "seat B deck after turn-1 draw");
|
||||
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(5), "seat B hand: 3 mulligan + 2 turn-1 draws");
|
||||
Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(25), "seat B deck: 30 - 3 mulligan - 2 draws");
|
||||
|
||||
// Both leaders untouched (no damage dealt across the two opening turns) — state tracks
|
||||
// cleanly on BOTH seats at the turn boundary.
|
||||
@@ -213,6 +211,140 @@ public class HeadlessConductorTests
|
||||
Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seat_A_play_after_partial_mulligan_finds_kept_card()
|
||||
{
|
||||
// Regression: a partial mulligan (swap 1 of 3) must leave the kept cards in hand.
|
||||
// Matches live battle 175320039619: A (cl2, Forestcraft) swaps idx 1,2 (keeps 3).
|
||||
// Includes BOTH client Swaps + server Swap responses (the full live frame stream).
|
||||
var aDeck = new List<long> { 101121080,102131020,100111010,102121030,101121020,101121110,101114010,100111010,102141010,102121010,101121020,102131030,701141011,100111020,101131050,100111020,100111070,101121010,100111070,101121080,100114010,101121110,101114050,101114050,100114010,100114010,102111060,113011010,102121030,102131010,100111020,101114050,101121080,101121010,101131020,113011010,113011010,101114010,102111060,102121010 };
|
||||
var bDeck = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList();
|
||||
using var harness = NodeNativeBattleHarness.Create(
|
||||
seatADeck: aDeck, seatBDeck: bDeck,
|
||||
seatAClass: CardClass.Forestcraft, seatBClass: CardClass.Runecraft);
|
||||
|
||||
harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
|
||||
|
||||
// Client Swap from A (idxList only — no "self")
|
||||
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["idxList"] = new List<object?> { 2, 1 } }, isPlayerSeat: true);
|
||||
// Server Swap response to A
|
||||
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["self"] = PosIdxList((0, 4), (1, 5), (2, 3)) }, isPlayerSeat: true);
|
||||
// Client Swap from B (no mulligan)
|
||||
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["idxList"] = new List<object?>() }, isPlayerSeat: false);
|
||||
// Server Swap response to B
|
||||
harness.Push(NetworkBattleUri.Swap, new Dictionary<string, object?> { ["self"] = PosIdxList((0, 1), (1, 2), (2, 3)) }, isPlayerSeat: false);
|
||||
|
||||
// Ready (from A's perspective)
|
||||
harness.Push(NetworkBattleUri.Ready, new Dictionary<string, object?>
|
||||
{
|
||||
["self"] = PosIdxList((0, 4), (1, 5), (2, 3)),
|
||||
["oppo"] = PosIdxList((0, 1), (1, 2), (2, 4)),
|
||||
["idxChangeSeed"] = 1463392880, ["spin"] = 0,
|
||||
}, isPlayerSeat: false);
|
||||
|
||||
harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true);
|
||||
|
||||
var handIdxs = Enumerable.Range(0, harness.HandCount(playerSeat: true))
|
||||
.Select(i => harness.HandCardIndex(playerSeat: true, i)).ToList();
|
||||
TestContext.WriteLine($"A hand after T1: [{string.Join(",", handIdxs)}]");
|
||||
Assert.That(handIdxs, Does.Contain(3), "kept card idx 3 must be in A hand");
|
||||
|
||||
var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(3), isPlayerSeat: true);
|
||||
Assert.That(play.Accepted, Is.True, $"A play idx 3 rejected: {play.RejectReason}");
|
||||
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "A board after play");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seed_deck_advances_cardTotalNum_so_tokens_dont_collide_with_deck_indices()
|
||||
{
|
||||
// Regression for the engine-divergence diagnosed 2026-06-07 (bid 806245601092).
|
||||
//
|
||||
// The real client's SBattleLoad.InitPlayer (SBattleLoad.cs:1292) loads the 40-card deck at
|
||||
// indices 1..40 and THEN sets `cardTotalNum = deck.Count + 1` (== 41), so the first
|
||||
// skill-generated token (via BattleManagerBase.SetupCardIndex with addIndex=-1) gets Index
|
||||
// 41 — exactly what the wire `add.idx` carries (e.g. `{"add":{"idx":[41,42],...}}`).
|
||||
//
|
||||
// The headless SessionBattleEngine.SeedDeck used to omit that tail, leaving `cardTotalNum`
|
||||
// at the property default (0). The first generated token then got Index 0, the second got
|
||||
// Index 1, and they COLLIDED with deck-loaded cards at the same indices. The collision was
|
||||
// silent until something addressed the deck card with the colliding Index: Hoverboarder at
|
||||
// deck idx 1 made GetBattleCardIdx's SingleOrDefault find TWO Index-1 cards and throw
|
||||
// "Sequence contains more than one matching element".
|
||||
//
|
||||
// The contract verified here: after Setup, `cardTotalNum` MUST equal `deck.Count + 1` on
|
||||
// both seats. This pins SBattleLoad's tail behavior in the headless engine.
|
||||
const int deckSize = 30; // NodeNativeBattleHarness.DefaultDeck is 30 cards
|
||||
using var harness = NodeNativeBattleHarness.Create();
|
||||
Assert.That(harness.IsReady, Is.True, "engine seats headless");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(harness.DebugCardTotalNum(playerSeat: true), Is.EqualTo(deckSize + 1),
|
||||
"seat A cardTotalNum must be deck.Count+1 after Setup (= next token Index >= deck.Count+1)");
|
||||
Assert.That(harness.DebugCardTotalNum(playerSeat: false), Is.EqualTo(deckSize + 1),
|
||||
"seat B cardTotalNum must be deck.Count+1 after Setup");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Engine_stableRandom_seed_aligns_with_wire_seed_clients_receive()
|
||||
{
|
||||
// Regression for the shadow-engine desync diagnosed 2026-06-07 (bid 654473755566).
|
||||
//
|
||||
// CLIENTS seed System.Random with Matched.seed (BattleManagerBase.cs:721), which the node
|
||||
// sends as BattleSeeds.Stable(MasterSeed) (InitBattleHandler.cs:28). The engine must seed its
|
||||
// _stableRandom with the SAME value; otherwise the very first NextDouble() returns a different
|
||||
// number, every turn-1+ StableRandom-driven draw picks a different deck position, and the
|
||||
// opponent's first non-mulligan play addresses a card the engine never drew → HandCardToField
|
||||
// throws.
|
||||
//
|
||||
// Before the fix, engine.Setup received the raw MasterSeed (1184631275 in the live battle),
|
||||
// while clients received BattleSeeds.Stable(MasterSeed) (=1543475792). After the fix,
|
||||
// BattleSession.EnsureEngineSetup + NodeNativeBattleHarness.Create both pass the Stable-derived
|
||||
// value, so both streams produce the same NextDouble sequence.
|
||||
const int masterSeed = 1184631275; // the bid 654473755566 master seed
|
||||
int wireSeed = BattleSeeds.Stable(masterSeed);
|
||||
|
||||
// The first NextDouble a fresh client would consume (turn-1 first-player draw is the very
|
||||
// first _stableRandom consumer — Deal/Swap/Ready don't touch _stableRandom).
|
||||
double expectedFirstDouble = new System.Random(wireSeed).NextDouble();
|
||||
|
||||
using var harness = NodeNativeBattleHarness.Create(masterSeed: masterSeed);
|
||||
Assert.That(harness.IsReady, Is.True, "engine seats headless");
|
||||
|
||||
double engineFirstDouble = harness.DebugStableRandomDouble();
|
||||
Assert.That(engineFirstDouble, Is.EqualTo(expectedFirstDouble),
|
||||
$"engine _stableRandom must be seeded with BattleSeeds.Stable({masterSeed})={wireSeed} " +
|
||||
"(the value Matched.seed ships clients); otherwise turn-1+ draws desync from the clients.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seat_B_vanilla_play_resolves_on_engine_state()
|
||||
{
|
||||
// Seat B (opponent/enemy) plays a vanilla follower on its first turn. Uses an all-vanilla
|
||||
// deck so no spell-path interference. Verifies the doesPlayerGoFirst:true seat mapping
|
||||
// lets B's play resolve through the engine (hand→board mutation).
|
||||
var allVanilla = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList();
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: allVanilla, seatBDeck: allVanilla);
|
||||
|
||||
harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
|
||||
harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true);
|
||||
harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false);
|
||||
|
||||
// A's turn
|
||||
harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true);
|
||||
harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true);
|
||||
|
||||
// B's turn (second player, draws 2)
|
||||
harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false);
|
||||
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(5), "B hand: 3 mulligan + 2 draws");
|
||||
|
||||
var bPlay = harness.Push(NetworkBattleUri.PlayActions, PlayBody(3), isPlayerSeat: false);
|
||||
Assert.That(bPlay.Accepted, Is.True, $"B play rejected: {bPlay.RejectReason}");
|
||||
Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(4), "B hand after play");
|
||||
Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "B board after play");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Opponent_reveal_seats_card_on_seat_B_headless()
|
||||
{
|
||||
@@ -229,7 +361,7 @@ public class HeadlessConductorTests
|
||||
Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted,
|
||||
Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
@@ -294,7 +426,7 @@ public class HeadlessConductorTests
|
||||
Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted,
|
||||
Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
@@ -335,7 +467,7 @@ public class HeadlessConductorTests
|
||||
// --- mulligan + open seat A turn 1 ------------------------------------------------------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
|
||||
@@ -391,7 +523,7 @@ public class HeadlessConductorTests
|
||||
// --- mulligan + seat A turn 1: play the 1/1 -------------------------------------------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1");
|
||||
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A 1/1 on board");
|
||||
@@ -448,7 +580,7 @@ public class HeadlessConductorTests
|
||||
// --- mulligan + open seat A turn 1, play the vanilla onto seat A's board --------------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted,
|
||||
@@ -530,7 +662,7 @@ public class HeadlessConductorTests
|
||||
// --- mulligan + open seat A turn 1, end it (no enemy target yet) -----------------------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
|
||||
|
||||
@@ -634,7 +766,7 @@ public class HeadlessConductorTests
|
||||
// seat A turn 1: play a 1/2 onto seat A's board.
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart (A)");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/2 (A)");
|
||||
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "one seat A follower on board");
|
||||
@@ -686,7 +818,7 @@ public class HeadlessConductorTests
|
||||
// seat A turn 1: play a 1/1.
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1");
|
||||
int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0);
|
||||
@@ -729,7 +861,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
|
||||
int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0);
|
||||
@@ -767,6 +899,65 @@ public class HeadlessConductorTests
|
||||
"the UN-chosen token (A) must NOT be added — the engine resolved the specific chosen branch");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Choice_play_resolves_under_wrapped_selectCard_wire_shape()
|
||||
{
|
||||
// Regression for the engine silently-dropped Choice play diagnosed 2026-06-07
|
||||
// (bid 131549100204): the SENDER's live wire wraps selectCard as
|
||||
// selectCard:{cardId:[<tokenId>], open:0}
|
||||
// (verified in data_dumps/captures/battle_test/cl1/battle-traffic.ndjson at the Resonance
|
||||
// play of idx 20). The engine's receive parser reads selectCard via ConvertToListInt
|
||||
// (NetworkBattleReceiver.cs:1202), which does `value as List<object>` — a Dictionary value
|
||||
// casts to null and the inner foreach NREs. The surrounding ConvertReceiveDataToMakeData has
|
||||
// a swallow-all catch (NetworkBattleReceiver.cs:1255-1260) that logs to Debug.LogError +
|
||||
// LocalLog — both shimmed/no-op'd headlessly — and returns false; SessionBattleEngine.Receive
|
||||
// calls ReceivedMessage with checkBreakData:false, so the false isn't propagated. The play
|
||||
// continues with choiceIdList=[], never moves the card from hand to board, and any LATER
|
||||
// targeted play that addresses the un-resolved card by Index (e.g. a bounce spell) crashes
|
||||
// with a null target.
|
||||
//
|
||||
// Fix: SessionBattleEngine.TranslateChoiceKeyAction unwraps the wrapped selectCard on the
|
||||
// engine's own dict copy before the receiver sees it (sibling to TranslateTargetOwners). The
|
||||
// unwrap is purely a shadow-ingest shape transformation — production engine code is
|
||||
// unchanged, and the opponent-facing relay (which never carries selectCard at all) is
|
||||
// untouched. After the unwrap, the same resolution path that the existing flat-list test
|
||||
// (Choice_play_resolves_chosen_branch_on_engine_state_headless) exercises must produce the
|
||||
// same outcome.
|
||||
var seatADeck = Enumerable.Repeat(NodeNativeBattleHarness.ChoiceCardId, 30).ToList();
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: seatADeck);
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
|
||||
int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0);
|
||||
int ppBefore = harness.Pp(playerSeat: true);
|
||||
const long chosen = NodeNativeBattleHarness.ChoiceTokenB;
|
||||
|
||||
// Drive the play using the WRAPPED wire shape — the exact form a live client emits.
|
||||
var play = harness.Push(
|
||||
NetworkBattleUri.PlayActions,
|
||||
NodeNativeBattleHarness.ChoicePlayBodyWrapped(choiceIdx, NodeNativeBattleHarness.ChoiceCardId, chosen),
|
||||
isPlayerSeat: true);
|
||||
|
||||
Assert.That(play.Accepted, Is.True, $"wrapped-selectCard choice play rejected: {play.RejectReason}");
|
||||
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
|
||||
"the choice card's cost must charge PP — confirms the play actually resolved, not silently dropped");
|
||||
|
||||
bool chosenInHand = false;
|
||||
for (int i = 0; i < harness.HandCount(playerSeat: true); i++)
|
||||
if (harness.HandCardId(playerSeat: true, i) == (int)chosen) { chosenInHand = true; break; }
|
||||
Assert.That(chosenInHand, Is.True,
|
||||
"the chosen token (B) must land in seat A's hand — proves the CHOSEN branch resolved through the wrapped wire shape");
|
||||
|
||||
bool otherInHand = false;
|
||||
for (int i = 0; i < harness.HandCount(playerSeat: true); i++)
|
||||
if (harness.HandCardId(playerSeat: true, i) == (int)NodeNativeBattleHarness.ChoiceTokenA) { otherInHand = true; break; }
|
||||
Assert.That(otherInHand, Is.False,
|
||||
"the UN-chosen token (A) must NOT be added — decisive that the unwrap forwarded the SPECIFIC chosen id, not a default or both");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deal_seats_three_card_hand_headless()
|
||||
{
|
||||
@@ -845,7 +1036,7 @@ public class HeadlessConductorTests
|
||||
Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted,
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted,
|
||||
Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
@@ -935,7 +1126,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
|
||||
@@ -1049,7 +1240,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
|
||||
// Ramp to seat A turn 3 (PP 3) so the cost-3 grantor is affordable.
|
||||
RampToSeatATurn(harness, targetTurn: 3);
|
||||
@@ -1110,7 +1301,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
RampToSeatATurn(harness, targetTurn: 3);
|
||||
|
||||
int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId);
|
||||
@@ -1194,7 +1385,7 @@ public class HeadlessConductorTests
|
||||
// --- mulligan + open seat A turn 1, play a vanilla follower onto seat A's board -------------
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
|
||||
@@ -1295,7 +1486,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
|
||||
@@ -1322,7 +1513,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
|
||||
@@ -1347,7 +1538,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
|
||||
@@ -1442,7 +1633,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)");
|
||||
@@ -1477,7 +1668,7 @@ public class HeadlessConductorTests
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart");
|
||||
|
||||
int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Lifecycle;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
@@ -219,7 +220,10 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
var shuffledB = state.GetShuffledDeck(b);
|
||||
|
||||
var engine = new SessionBattleEngine();
|
||||
engine.Setup(state.MasterSeed, shuffledA, shuffledB,
|
||||
// Mirror BattleSession.EnsureEngineSetup: engine's StableRandom is seeded with
|
||||
// BattleSeeds.Stable(MasterSeed), the value the Matched frame ships to clients
|
||||
// (InitBattleHandler.cs:28). See BattleSession.cs for the full root-cause comment.
|
||||
engine.Setup(BattleSeeds.Stable(state.MasterSeed), shuffledA, shuffledB,
|
||||
(int)a.Context.ClassId, (int)b.Context.ClassId);
|
||||
|
||||
return new NodeNativeBattleHarness(state, a, b, engine, shuffledA, shuffledB);
|
||||
@@ -259,6 +263,17 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
/// <summary>The engine Index of the hand card at <paramref name="handPos"/> on the given seat.</summary>
|
||||
public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos);
|
||||
|
||||
/// <summary>TEST/DEBUG: pull one value from the engine's shared <c>_stableRandom</c> stream. Mirrors the
|
||||
/// engine's <see cref="SessionBattleEngine.DebugStableRandomDouble"/> seam; lets a regression test
|
||||
/// assert seed alignment with the wire (clients seed their <c>_stableRandom</c> with the Matched.seed,
|
||||
/// which is <c>BattleSeeds.Stable(masterSeed)</c>).</summary>
|
||||
public double DebugStableRandomDouble() => Engine.DebugStableRandomDouble();
|
||||
|
||||
/// <summary>TEST/DEBUG: read the seat's auto-assign Index counter (<c>cardTotalNum</c>). After
|
||||
/// Setup it must equal <c>deck.Count + 1</c> so the next skill-generated token gets an Index
|
||||
/// clear of the deck-loaded 1..40 (= the real client's SBattleLoad behavior).</summary>
|
||||
public int DebugCardTotalNum(bool playerSeat) => Engine.DebugCardTotalNum(playerSeat);
|
||||
|
||||
/// <summary>The real wire <c>CardId</c> of the in-play follower at <paramref name="boardPos"/> on the
|
||||
/// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity
|
||||
/// (M-HC-2).</summary>
|
||||
@@ -286,6 +301,19 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
/// <summary>Turns remaining until the seat may evolve (0 == unlocked) (M-HC-4b).</summary>
|
||||
public int EvolveWaitTurnCount(bool playerSeat) => Engine.EvolveWaitTurnCount(playerSeat);
|
||||
|
||||
// --- TEST/DEBUG seams (Phase 4 root-cause verification: post-mulligan reshuffle) ---------------
|
||||
|
||||
/// <summary>TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active (the gate the
|
||||
/// post-mulligan reshuffle checks)? Live recovery setup leaves it FALSE.</summary>
|
||||
public bool SelfXorShiftActive => Engine.SelfXorShiftActive;
|
||||
|
||||
/// <summary>TEST/DEBUG: opponent-seat XorShift active state.</summary>
|
||||
public bool OppoXorShiftActive => Engine.OppoXorShiftActive;
|
||||
|
||||
/// <summary>TEST/DEBUG: inject the per-seat idxChange seeds (call before the Ready mulligan-end frame
|
||||
/// to activate the engine's own post-mulligan reshuffle).</summary>
|
||||
public void DebugSeedIdxChange(int selfSeed, int oppoSeed) => Engine.DebugSeedIdxChange(selfSeed, oppoSeed);
|
||||
|
||||
/// <summary>Build an envelope for <paramref name="body"/> and ingest it into the engine for the
|
||||
/// given seat (player == seat A). Mirrors <c>BattleNodeFlowTests.MakeEnvelopeWith</c> +
|
||||
/// <c>SessionBattleEngine.Receive</c>.</summary>
|
||||
@@ -420,6 +448,36 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>VERBATIM CLIENT-SEND Choice play shape — the wrapped form
|
||||
/// <c>selectCard:{cardId:[<tokenId>], open:<0|1>}</c> the sender's wire actually carries
|
||||
/// (data_dumps/captures/battle_test/cl1/battle-traffic.ndjson, live bid 131549100204:
|
||||
/// <c>"selectCard":{"cardId":[121011010],"open":0}</c>). The shadow engine's ingest receives this
|
||||
/// wrapper directly (the node strips selectCard from the opponent broadcast, so opponent-facing
|
||||
/// frames never see it); <see cref="Engine.SessionBattleEngine.TranslateChoiceKeyAction"/>
|
||||
/// unwraps it on the engine's own dict copy before the receiver parses keyAction. This driver
|
||||
/// exists so a regression test can pin that unwrap end-to-end against the SAME shape the live
|
||||
/// wire delivers, distinct from <see cref="ChoicePlayBody"/> which fast-paths the flat list.
|
||||
/// <paramref name="open"/> defaults to 0 (choice hidden from opponent) — the value the live
|
||||
/// capture carries; flag is dropped by the unwrap and irrelevant to resolution.</summary>
|
||||
public static Dictionary<string, object?> ChoicePlayBodyWrapped(int playIdx, long playedCardId, long chosenTokenId, int open = 0) => new()
|
||||
{
|
||||
["playIdx"] = playIdx,
|
||||
["type"] = 30,
|
||||
["keyAction"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = "Choice",
|
||||
["cardId"] = playedCardId,
|
||||
["selectCard"] = new Dictionary<string, object?>
|
||||
{
|
||||
["cardId"] = new List<object?> { chosenTokenId },
|
||||
["open"] = open,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>The engine's <c>NetworkBattleDefine.PlayActionType.EVOLUTION</c> opcode — confirmed
|
||||
/// <c>= 20</c> in <c>SVSim.BattleEngine/Engine/NetworkBattleDefine.cs</c> (EVOLUTION_SELECT is 21). The
|
||||
/// receiver maps the wire <c>type</c> int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// PHASE 4 STEP 1 — root-cause VERIFICATION (NOT a fix). Tier 1 mechanism proof for the reported PvP
|
||||
/// spellboost:0 / "Target card was not found in hand cards" desync.
|
||||
///
|
||||
/// THE CHAIN (already traced from engine source, re-confirmed here by behavior):
|
||||
/// - The node runs the engine with <c>mgr.IsRecovery = true</c>. Under IsRecovery the engine seeds its
|
||||
/// post-mulligan deck-reshuffle RNG from <c>RecoveryManager.IdxChangeSeed</c>
|
||||
/// (NetworkBattleManagerBase.cs:259-261). The node's RecoveryManager is <c>NullRecoveryManager</c>,
|
||||
/// whose <c>IdxChangeSeed == -1</c>, so the engine runs <c>CreateXorShift(-1, -1)</c>.
|
||||
/// - <c>CreateXorShift</c> only builds an <c>XorShift</c> when <c>seed != -1</c>
|
||||
/// (BattleManagerBase.cs:806-815), and <c>new XorShift(-1)</c> sets <c>IsActive = false</c>
|
||||
/// (BattleManagerBase.cs:48). So both seats' XorShift stay null/inactive.
|
||||
/// - The post-mulligan deck reshuffle + card re-index (<c>AddToDeck</c> gate at BattlePlayerBase.cs:3049
|
||||
/// queues returned cards; <c>AddToDeckCardIndexChange</c> at 3073-3084 repositions/renumbers them) is
|
||||
/// gated on <c>XorShiftRandom(...) != null && .IsActive && IsMulliganEnd</c>. With the XorShift
|
||||
/// inactive the engine SKIPS the reshuffle the real clients performed (each client used the per-seat
|
||||
/// <c>idxChangeSeed</c> the node sent in its Ready frame: cl1=1430655717, cl2=661650374).
|
||||
/// - Result: the engine's post-mulligan deck order + Index values diverge from the clients'. A client
|
||||
/// play of (e.g.) idx8 finds no Index==8 card in the engine hand -> HandCardToField throws -> the
|
||||
/// shadow logs "diverged"; downstream Played* reads fall back to 0 -> opponent sees spellboost:0.
|
||||
///
|
||||
/// This file proves the MECHANISM headless and deterministically:
|
||||
/// (1) the live-shaped setup leaves the XorShift inactive (reshuffle skipped);
|
||||
/// (2) seeding it (the verification-only <c>DebugSeedIdxChange</c> hook) ACTIVATES the engine's OWN
|
||||
/// reshuffle, changing the post-mulligan draw order/indices vs the un-seeded run.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class PostMulliganReshuffleRootCauseTests
|
||||
{
|
||||
// --- frame bodies (same wire shapes the node emits; mirror HeadlessConductorTests) -------------
|
||||
|
||||
private static List<object?> PosIdxList(params (int pos, int idx)[] entries)
|
||||
{
|
||||
var list = new List<object?>(entries.Length);
|
||||
foreach (var (pos, idx) in entries)
|
||||
list.Add(new Dictionary<string, object?> { ["pos"] = pos, ["idx"] = idx });
|
||||
return list;
|
||||
}
|
||||
|
||||
// Opening deal: top 3 of each shuffled deck (idx 1,2,3).
|
||||
private static Dictionary<string, object?> DealBody() => new()
|
||||
{
|
||||
["self"] = PosIdxList((0, 1), (1, 2), (2, 3)),
|
||||
["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)),
|
||||
};
|
||||
|
||||
// Mulligan AWAY the pos-2 card (deck idx 3) -> the server hands back the next unused deck idx (4).
|
||||
// The mulliganed-away card returns to the deck; under an ACTIVE XorShift that return triggers the
|
||||
// reshuffle/re-index. Under the live (inactive) setup it does not.
|
||||
private static Dictionary<string, object?> SwapBody() => new()
|
||||
{
|
||||
["self"] = PosIdxList((0, 1), (1, 2), (2, 4)),
|
||||
};
|
||||
|
||||
// Ready seals the mulligan (sets IsMulliganEnd) and starts turn 1.
|
||||
//
|
||||
// CRUCIAL FIDELITY POINT (the live root cause): the real Ready frame is SERVER-AUTHORED and travels
|
||||
// server->client (it is a "receive" in every capture; no client SEND frame carries idxChangeSeed).
|
||||
// The live node's BattleSession.ShadowIngest feeds the engine ONLY inbound participant SENDS — so the
|
||||
// shadow engine NEVER ingests the Ready frame, and the receiver's idxChangeSeed -> CreateXorShift path
|
||||
// (NetworkBattleReceiver.cs:1125-1126) NEVER runs for the shadow. We model that here by carrying
|
||||
// idxChangeSeed = -1 (the "engine never received a real seed" state), then optionally injecting the
|
||||
// seed out-of-band via DebugSeedIdxChange to prove the seed is what drives the reshuffle.
|
||||
private static Dictionary<string, object?> ReadyBody() => new()
|
||||
{
|
||||
["self"] = PosIdxList((0, 1), (1, 2), (2, 4)),
|
||||
["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)),
|
||||
["idxChangeSeed"] = -1,
|
||||
["spin"] = 0,
|
||||
};
|
||||
|
||||
private static Dictionary<string, object?> TurnStartBody() => new() { ["spin"] = 0 };
|
||||
|
||||
// The fresh smoke-capture per-seat idxChange seeds (battle 907324319325): cl1 = seat A self,
|
||||
// cl2 = seat B. In the live recovery path only the SELF seed is consumed (oppIdxSeed = -1); we pass
|
||||
// cl2 as the oppo seed here to also activate seat B's reshuffle for the symmetry check.
|
||||
private const int Cl1SelfSeed = 1430655717;
|
||||
private const int Cl2Seed = 661650374;
|
||||
|
||||
/// <summary>Drive Deal + Swap + Ready + turn-1 TurnStart and return seat A's post-draw hand as
|
||||
/// (Index, CardId) pairs in hand order. <paramref name="seedIdxChange"/> injects the idxChange seeds
|
||||
/// BEFORE the mulligan ops (Swap/Ready), so the engine's own reshuffle is active when the abandoned
|
||||
/// mulligan card is returned to the deck (MulliganCtrl._ReturnAbandonToDeck -> AddToDeck, whose
|
||||
/// reshuffle gate checks XorShift active AT RETURN TIME) and re-indexed on the next TurnStart.</summary>
|
||||
private static (List<(int Index, int CardId)> hand, bool selfActive, bool oppoActive, int deckCount) DriveToTurn1(
|
||||
bool seedIdxChange)
|
||||
{
|
||||
// Deck with DISTINCT card identities across the first ~12 positions so a reshuffle is observable in
|
||||
// CardId (not just Index). All ids are known-creatable headless (harness constants, sourced from the
|
||||
// tk2 capture / engine tests). Position 30 is padded with the proven vanilla.
|
||||
var distinctTop = new long[]
|
||||
{
|
||||
NodeNativeBattleHarness.VanillaFollowerId, // idx 1
|
||||
NodeNativeBattleHarness.AltVanillaFollowerId, // idx 2
|
||||
NodeNativeBattleHarness.VanillaOneOneFollowerId, // idx 3
|
||||
NodeNativeBattleHarness.HighLifeVanillaFollowerId, // idx 4
|
||||
NodeNativeBattleHarness.SpellboostCardId, // idx 5
|
||||
NodeNativeBattleHarness.SpellboostCardIdAlt, // idx 6
|
||||
NodeNativeBattleHarness.ClanTribeFollowerId, // idx 7
|
||||
NodeNativeBattleHarness.ChoiceCardId, // idx 8
|
||||
NodeNativeBattleHarness.BoardDependentCostCardId,// idx 9
|
||||
NodeNativeBattleHarness.ChoiceTokenA, // idx 10
|
||||
NodeNativeBattleHarness.ChoiceTokenB, // idx 11
|
||||
};
|
||||
var deck = new List<long>(distinctTop);
|
||||
deck.AddRange(Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30 - deck.Count));
|
||||
|
||||
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck, seatBDeck: deck);
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
|
||||
|
||||
// Seed BEFORE the mulligan ops so the XorShift is active when the abandoned card returns to deck.
|
||||
if (seedIdxChange)
|
||||
harness.DebugSeedIdxChange(Cl1SelfSeed, Cl2Seed);
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
|
||||
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready");
|
||||
|
||||
bool selfActive = harness.SelfXorShiftActive;
|
||||
bool oppoActive = harness.OppoXorShiftActive;
|
||||
|
||||
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted,
|
||||
Is.True, "turn1 TurnStart");
|
||||
|
||||
int handCount = harness.HandCount(playerSeat: true);
|
||||
var hand = new List<(int, int)>(handCount);
|
||||
for (int i = 0; i < handCount; i++)
|
||||
hand.Add((harness.HandCardIndex(playerSeat: true, i), harness.HandCardId(playerSeat: true, i)));
|
||||
|
||||
return (hand, selfActive, oppoActive, harness.DeckCount(playerSeat: true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Live_recovery_setup_leaves_XorShift_inactive_so_reshuffle_is_skipped()
|
||||
{
|
||||
// The harness seats the engine EXACTLY as BattleSession does (IsRecovery=true, no idxChange seed),
|
||||
// so the XorShift must be inactive on BOTH seats — the live (broken) state.
|
||||
var (handCurrent, selfActive, oppoActive, _) = DriveToTurn1(seedIdxChange: false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(selfActive, Is.False,
|
||||
"LIVE BUG: seat A XorShift inactive (CreateXorShift(-1,-1)) -> post-mulligan reshuffle SKIPPED");
|
||||
Assert.That(oppoActive, Is.False, "seat B XorShift also inactive");
|
||||
});
|
||||
|
||||
TestContext.WriteLine("UN-SEEDED (live) turn-1 hand (Index:CardId): " +
|
||||
string.Join(", ", handCurrent.Select(h => $"{h.Index}:{h.CardId}")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seeding_idxChange_flips_the_reshuffle_gate_from_inactive_to_active()
|
||||
{
|
||||
// BEFORE: live setup, XorShift inactive -> the reshuffle gate (XorShiftRandom().IsActive, the EXACT
|
||||
// predicate BattlePlayerBase.cs:3049/3073 check) is CLOSED.
|
||||
var (handUnseeded, selfActiveU, _, _) = DriveToTurn1(seedIdxChange: false);
|
||||
// AFTER: inject the captured per-seat idxChange seeds -> the gate predicate is OPEN on both seats.
|
||||
var (handSeeded, selfActiveS, oppoActiveS, _) = DriveToTurn1(seedIdxChange: true);
|
||||
|
||||
TestContext.WriteLine("UN-SEEDED turn-1 hand (Index:CardId): " +
|
||||
string.Join(", ", handUnseeded.Select(h => $"{h.Index}:{h.CardId}")));
|
||||
TestContext.WriteLine("SEEDED turn-1 hand (Index:CardId): " +
|
||||
string.Join(", ", handSeeded.Select(h => $"{h.Index}:{h.CardId}")));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(selfActiveU, Is.False, "un-seeded: seat A reshuffle gate CLOSED (XorShift inactive)");
|
||||
Assert.That(selfActiveS, Is.True, "seeded: seat A reshuffle gate OPEN (XorShift active)");
|
||||
Assert.That(oppoActiveS, Is.True, "seeded: seat B reshuffle gate OPEN (oppo seed != -1)");
|
||||
});
|
||||
|
||||
// HEADLESS-PATH NOTE (documented limitation, NOT a contradiction of the root cause): the engine's
|
||||
// recovery mulligan path does not run the XorShift over the MULLIGAN cards. The reshuffle gate at
|
||||
// BattlePlayerBase.cs:3049 also requires IsMulliganEnd, and on the recovery path
|
||||
// (RecoveryOperationCollection.SecondMulliganOperation) the abandoned-card return (AddToDeck) runs
|
||||
// BEFORE IsMulliganEnd is set, so the mulligan cards are never queued into AddToDeckList. The
|
||||
// XorShift's GetChangeInt is consumed only by AddToDeckCardIndexChange (3079), i.e. cards added to
|
||||
// the deck AFTER mulligan-end (mid-battle bounce/shuffle effects). So the seeded vs un-seeded turn-1
|
||||
// hand is IDENTICAL headless via this flow — the gate flips, but no mulligan card flows through the
|
||||
// re-index headless. The end-to-end draw-divergence the seed drives is proven against the REAL wire
|
||||
// in the Tier-2 capture-replay test (CaptureReplayReshuffleRootCauseTests), where the engine draws
|
||||
// by its own (un-reshuffled) deck order while the capture's plays reference the client's
|
||||
// (reshuffled) order -> the counted "not found in hand" divergences. We assert the headless
|
||||
// invariance here so the limitation is pinned, not hidden.
|
||||
Assert.That(handSeeded.Select(h => (h.Index, h.CardId)),
|
||||
Is.EqualTo(handUnseeded.Select(h => (h.Index, h.CardId))),
|
||||
"headless mulligan flow does not route mulligan cards through the XorShift re-index (see note) — " +
|
||||
"the seed's draw effect is proven end-to-end in the Tier-2 capture-replay test");
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ public class ServerBattleFramesTests
|
||||
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000);
|
||||
var body = (ReadyBody)env.Body;
|
||||
Assert.That(body.IdxChangeSeed, Is.EqualTo(555_000));
|
||||
Assert.That(body.Spin, Is.EqualTo(243));
|
||||
Assert.That(body.Spin, Is.EqualTo(0));
|
||||
Assert.That(body.Self[1].Idx, Is.EqualTo(4));
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ public class TypedBodyWireShapeTests
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
|
||||
Assert.That(node["idxChangeSeed"]!.GetValue<int>(), Is.EqualTo(771_335_280));
|
||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(243));
|
||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(0));
|
||||
Assert.That(node["self"]!.AsArray().Count, Is.EqualTo(3));
|
||||
Assert.That(node["oppo"]!.AsArray().Count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
@@ -44,12 +44,12 @@ public class HandBodiesTests
|
||||
Self: new[] { new PosIdx(0, 1) },
|
||||
Oppo: new[] { new PosIdx(0, 1) },
|
||||
IdxChangeSeed: 771_335_280,
|
||||
Spin: 243);
|
||||
Spin: 0);
|
||||
|
||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||
|
||||
Assert.That(node["idxChangeSeed"]!.GetValue<int>(), Is.EqualTo(771_335_280));
|
||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(243));
|
||||
Assert.That(node["spin"]!.GetValue<int>(), Is.EqualTo(0));
|
||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user