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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user