diff --git a/SVSim.BattleEngine.Tests/BattleCardViewShimTests.cs b/SVSim.BattleEngine.Tests/BattleCardViewShimTests.cs new file mode 100644 index 0000000..c929b01 --- /dev/null +++ b/SVSim.BattleEngine.Tests/BattleCardViewShimTests.cs @@ -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."); + } + } +} diff --git a/SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl1.ndjson b/SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl1.ndjson new file mode 100644 index 0000000..3542383 --- /dev/null +++ b/SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl1.ndjson @@ -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}} diff --git a/SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl2.ndjson b/SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl2.ndjson new file mode 100644 index 0000000..105bc48 --- /dev/null +++ b/SVSim.BattleEngine.Tests/Fixtures/battle_test_fresh_cl2.ndjson @@ -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}} diff --git a/SVSim.BattleEngine.Tests/HeadlessHandViewStubTests.cs b/SVSim.BattleEngine.Tests/HeadlessHandViewStubTests.cs new file mode 100644 index 0000000..1f6598a --- /dev/null +++ b/SVSim.BattleEngine.Tests/HeadlessHandViewStubTests.cs @@ -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); + } + } +} diff --git a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayFullInputDivergenceExperimentTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayFullInputDivergenceExperimentTests.cs new file mode 100644 index 0000000..c2f0924 --- /dev/null +++ b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayFullInputDivergenceExperimentTests.cs @@ -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 +{ + /// + /// 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 idxChangeSeed) 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 playIdx. + /// + /// SEEDING MECHANISM (clean, both seats): the seat-B Ready ingest throws an NRE headless (the + /// recovery deal path isn't headless-clean for the opponent seat), so the wire Ready 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 . + /// DebugSeedIdxChange(self, oppo) (-> BattleManagerBase.CreateXorShift) 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 SelfXorShiftActive and OppoXorShiftActive + /// are true after. + /// + /// SETUP-FRAME INGEST: identical mechanism to — a + /// single Deal (cl1's receive Deal seats BOTH hands), each seat's Swap (its mulligan), + /// each seat's Ready (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. + /// + [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 SkipUris = new() + { + nameof(NetworkBattleUri.Echo), + nameof(NetworkBattleUri.ChatStamp), + nameof(NetworkBattleUri.Gungnir), + }; + + private static readonly HashSet 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 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 cl1, IReadOnlyList 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 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"]!; + } + } +} diff --git a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayRandomDrawSpinRootCauseTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayRandomDrawSpinRootCauseTests.cs new file mode 100644 index 0000000..855518a --- /dev/null +++ b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayRandomDrawSpinRootCauseTests.cs @@ -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 +{ + /// + /// 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 + /// mgr.StableRandom(...) (SkillRandomSelectFilter.Filtering:49/58), gated by the process-global + /// BattleManagerBase.IsRandomDraw — which the real match-load sets true via + /// StartOpening → SetupInitialGameState(areCardsRandomlyDrawn:true) (BattleManagerBase.cs:1098/1110). + /// The headless .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 _stableRandom stream must be advanced by the wire spin pre-roll the Ready + /// frame carries (spin=243), which OperateReceive.StartOperate:80-83 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 _stableRandom 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.) + /// + [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 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 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 cl1, IReadOnlyList 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})"))); + } + + /// 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. + [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}"); + } + } + } +} diff --git a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayReshuffleRootCauseTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayReshuffleRootCauseTests.cs new file mode 100644 index 0000000..f5bde5c --- /dev/null +++ b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayReshuffleRootCauseTests.cs @@ -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 +{ + /// + /// 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 , then measures whether the per-seat idxChangeSeed + /// 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 + /// CreateXorShift 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 Matched.selfDeck (idx->cardId, the exact shuffled order the node also handed the + /// client) via ; the master seed from Matched.selfInfo.seed. + /// The deck IS in the socket capture — no external fixture needed. + /// + [TestFixture] + [NonParallelizable] + public class CaptureReplayReshuffleRootCauseTests + { + private static readonly HashSet SkipUris = new() + { + nameof(NetworkBattleUri.Echo), + nameof(NetworkBattleUri.ChatStamp), + nameof(NetworkBattleUri.Gungnir), + }; + + private static readonly HashSet MulliganUris = new() + { + nameof(NetworkBattleUri.Deal), + nameof(NetworkBattleUri.Swap), + nameof(NetworkBattleUri.Ready), + }; + + private sealed record ReplayOutcome( + int FrameCount, List 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()); + } + + /// Seat both hands from each client's receive Deal+Swap+Ready, then replay both clients' + /// interleaved SENDS. forces the Ready idxChangeSeed to -1 (the live + /// shadow's effective state). Returns divergences + the post-setup self XorShift state. + 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(); + 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 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)"); + }); + } + } +} diff --git a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayRngSeatAttributionProbeTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayRngSeatAttributionProbeTests.cs new file mode 100644 index 0000000..22ddb24 --- /dev/null +++ b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplayRngSeatAttributionProbeTests.cs @@ -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 +{ + /// + /// 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 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. + /// + [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 SkipUris = new() + { + nameof(NetworkBattleUri.Echo), + nameof(NetworkBattleUri.ChatStamp), + nameof(NetworkBattleUri.Gungnir), + }; + + private static CapturedFrame Receive(IReadOnlyList frames, string uri) => + frames.First(f => f.Direction == "receive" && f.Uri == uri); + + private static IEnumerable<(CapturedFrame Frame, bool Seat)> SendsInTsOrder( + IReadOnlyList cl1, IReadOnlyList 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 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 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 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."); + } + } +} diff --git a/SVSim.BattleEngine/Engine/RecoveryOperationCollection.cs b/SVSim.BattleEngine/Engine/RecoveryOperationCollection.cs index fa39ee9..79a011d 100644 --- a/SVSim.BattleEngine/Engine/RecoveryOperationCollection.cs +++ b/SVSim.BattleEngine/Engine/RecoveryOperationCollection.cs @@ -18,9 +18,13 @@ public class RecoveryOperationCollection : WatchOperationCollection public override void PlayHandCardOperation(PlayHandCardReflection networkPlayCardAction, List choiceIdList = null, bool isChoice = false) { - List actionDictionary = (_isPlayer ? _receivedData.PlayerTargetDataList : _receivedData.OpponentTargetDataList); - BattlePlayerBase battlePlayer = _networkBattleMgr.GetBattlePlayer(_isPlayer); - CommonPlayHandCardOperation(networkPlayCardAction, battlePlayer, _isPlayer, actionDictionary, choiceIdList, isChoice); + // Route ALL recovery hand-plays through PlayAction (the type:31 PLAY_HAND_SELECT path). + // PlayAction resolves targets from the receiver's target data and calls PlayActionMove, + // which bypasses PlayMove's two-phase user-select guard (the guard that aborts on targeted + // spells with SendEcho+return, waiting for a follow-up type:31 frame that never comes in + // recovery/shadow mode). PlayAction is the path RecoveryOperationCollection already uses + // for type:31; unifying type:30 here makes all spell plays resolve headless. + PlaySkillSelectHandCardOperation(networkPlayCardAction, choiceIdList); } public override void PlaySkillSelectHandCardOperation(PlayHandCardReflection networkPlayCardAction, List choiceIds = null) diff --git a/SVSim.BattleEngine/SVSim.BattleEngine.csproj b/SVSim.BattleEngine/SVSim.BattleEngine.csproj index 9dc3bae..e008965 100644 --- a/SVSim.BattleEngine/SVSim.BattleEngine.csproj +++ b/SVSim.BattleEngine/SVSim.BattleEngine.csproj @@ -18,4 +18,10 @@ true + + + + + diff --git a/SVSim.BattleEngine/Shim/View/HeadlessHandViewStub.cs b/SVSim.BattleEngine/Shim/View/HeadlessHandViewStub.cs index c85bc1a..1a8fb21 100644 --- a/SVSim.BattleEngine/Shim/View/HeadlessHandViewStub.cs +++ b/SVSim.BattleEngine/Shim/View/HeadlessHandViewStub.cs @@ -9,6 +9,7 @@ // the abstract RearrangeHand is never reached). Nothing here touches game state. using UnityEngine; +using Wizard.Battle.View.Vfx; namespace Wizard.Battle.View { @@ -24,5 +25,24 @@ namespace Wizard.Battle.View protected override void RearrangeHand(float rearrangeTime, bool isNewReplayMoveTurn = false) { } protected override HandControl CreateHandControl(GameObject handGameObject, BattleCamera battleCamera) => null; + + // HEADLESS-FIX: with CreateHandControl returning null, the base implementations of + // HandUnfocus/HandFocus/FocusRearrangeHandHand (HandViewBase.cs:124/133/142) NRE on + // `_handControl.SetHandState(...)`. These are PURE PRESENTATION methods — they ease the hand + // cards in/out visually as a side effect of leader healing, spell selection, etc. — with no + // game-state implications, so the safe headless behavior is a no-op returning NullVfx. + // + // Live regression: bid 799755786270 (2026-06-07). A follower with a `when_spell_play` Heal + // trigger fired on its leader for 0 (the trigger fires regardless of heal amount, and even a + // 0-heal still drives `ApplyHealing` → `CreatePullHandInVfx` → `HandView.HandUnfocus()` + // unconditionally per ClassBattleCardBase.cs:234/239). Stack: + // Skill_heal.Start → ClassBattleCardBase.ApplyHealing → CreatePullHandInVfx + // → HandViewBase.HandUnfocus → NRE on null _handControl. + // Same pattern as the metamorphose-NRE shim fix (ViewUiTouchStubs.cs's + // BattleCardView.GameObject lazy non-null): production Unity touches that the headless + // engine needs to no-op rather than throw. + public override VfxBase HandUnfocus() => NullVfx.GetInstance(); + public override VfxBase HandFocus() => NullVfx.GetInstance(); + public override VfxBase FocusRearrangeHandHand() => NullVfx.GetInstance(); } } diff --git a/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs b/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs index d3ea9df..29ab83e 100644 --- a/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs +++ b/SVSim.BattleEngine/Shim/View/ViewUiTouchStubs.cs @@ -28,7 +28,23 @@ namespace Wizard.Battle.View new InPlayCardFrameEffectControl(null, null, null); // AttackTargetSelectInfo provided by Generated/BattleCardView_AttackTargetSelectInfo.g.cs - public virtual UnityEngine.GameObject GameObject { get; protected set; } + // + // HEADLESS-FIX: lazily non-null GameObject so unguarded Unity touches on the IsRecovery + // path resolve as no-ops instead of NRE-ing on the shim's null default. Matches the + // existing Component.gameObject lazy pattern (UnityShim.cs:94). The IsRecovery card-create + // delegate (NetworkBattleManagerBase.cs:379) passes null for cardGameObject, which left + // BattleCardView.GameObject null and caused Skill_metamorphose.cs:147 (the in-play + // metamorphose branch — Petrification etc.) to NRE on + // `metamorphosedCard.BattleCardView.GameObject.transform.rotation = Quaternion.identity`, + // a purely cosmetic transform reset; making it a no-op preserves the surrounding state + // mutations (ReplaceInPlay, SetUpInplay, FlagCardAsDestroyedBySkill, RemoveFromInPlay). + // Live regression: bid 283192092460, A's Petrification on B's in-play card idx 1. + private UnityEngine.GameObject _gameObject; + public virtual UnityEngine.GameObject GameObject + { + get => _gameObject ??= new UnityEngine.GameObject(); + protected set => _gameObject = value; + } public HandCardFrameEffectControl HandFrameEffect { get; private set; } public static HandParameter.IconLayout GetCurrentIconLayout() => default!; } diff --git a/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs b/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs index 17525be..3f6fda0 100644 --- a/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs +++ b/SVSim.BattleNode/Lifecycle/BattleFrameDefaults.cs @@ -15,10 +15,13 @@ internal static class BattleFrameDefaults public const string PlayerRank = "10"; public const string PlayerBattlePoint = "6270"; - // From frame[8] (Ready). Provenance is "what prod sent"; the client doesn't validate. This is - // an animation crank value (shared-RNG spin), NOT gameplay randomness — both clients crank it - // identically and stay synced, so it stays a constant. See the spin-rng audit. - public const int ReadySpin = 243; + // Ready-frame spin. Prod shipped 243 (obfuscation base — the spin-rng audit proved ~99% of the + // magnitude is non-gameplay). Our node is authority for BOTH clients; they each crank this on + // their own shared _stableRandom, but the shadow engine ingests BOTH sides' Ready frames on ONE + // stream — so a non-zero value double-cranks the shadow (243×2 = 486 vs each client's 243), + // desynchronizing every subsequent StableRandom draw. Zero eliminates the offset; both clients + // and the shadow all start at stream position 0. + public const int ReadySpin = 0; /// /// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's diff --git a/SVSim.BattleNode/Protocol/MsgEnvelope.cs b/SVSim.BattleNode/Protocol/MsgEnvelope.cs index 0d505dd..f461195 100644 --- a/SVSim.BattleNode/Protocol/MsgEnvelope.cs +++ b/SVSim.BattleNode/Protocol/MsgEnvelope.cs @@ -153,7 +153,7 @@ public sealed record MsgEnvelope( return new MsgEnvelope(uri, viewerId, uuid, bid, retryAttempt, cat, pubSeq, playSeq, new RawBody(bodyDict)); } - private static object? ToObject(JsonElement el) => el.ValueKind switch + internal static object? ToObject(JsonElement el) => el.ValueKind switch { JsonValueKind.String => el.GetString(), // Extracted to a helper because writing the conditional inline as diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index 7d14a93..0eee1b9 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -1,5 +1,7 @@ using System.Net.WebSockets; +using System.Text.Json; using Microsoft.Extensions.Logging; +using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Sessions.Dispatch; using SVSim.BattleNode.Sessions.Dispatch.Handlers; @@ -35,6 +37,21 @@ public sealed class BattleSession /// never retried, never fatal. private bool _engineSetupAttempted; + /// Guards: server-generated Deal is fed to the shadow engine exactly once (the first + /// occurrence from either LoadedHandler invocation). Deal + Ready are server-generated frames the + /// engine needs to drive the mulligan: Deal → StartDeal (cards deck→hand for the player seat, + /// _firstDrawList for the opponent), Ready → CompleteMulligan → EnemyChangeCardVfx → opponent + /// DrawFirstMulliganCard. Without them the engine's hand stays empty and every play throws + /// "Target card was not found in hand cards". + private bool _engineDealFed; + + /// Guards: server-generated Ready is fed to the shadow engine exactly once (the first + /// Ready addressed to participant A). Fed as isPlayerSeat=false so the recovery path's + /// OperateMulligan enters the OperateOppoMulligan branch — the only branch that invokes + /// ReceiveOpponentMulligan → EnemyChangeCardVfx → DrawFirstMulliganCard. The player's mulligan + /// was already processed during the Swap feed. + private bool _engineReadyFed; + /// True once this session has acquired the process-wide /// (and is therefore the single active engine owner). Drives the matching Release at battle /// end so the next session can take the engine. @@ -241,13 +258,89 @@ public sealed class BattleSession } if (Handlers.TryGetValue(env.Uri, out var handler)) - return handler.Handle(BuildContext(from, env)); + { + var routes = handler.Handle(BuildContext(from, env)); + try { ShadowFeedServerFrames(routes); } + catch (Exception ex) + { + _log.LogWarning(ex, "BattleSession {Bid}: shadow engine error feeding server frames (ignored)", BattleId); + } + return routes; + } _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in lifecycle={Lifecycle} from vid={Vid}", BattleId, env.Uri, Lifecycle, from.ViewerId); return Array.Empty(); } + /// Feed server-generated mulligan frames (Deal, Swap response, Ready) into the shadow + /// engine. These frames are produced by LoadedHandler/SwapHandler and dispatched only to clients + /// — they never enter because they're not client-sent. But the engine + /// needs them to drive the mulligan: Deal seats the hand, Ready completes the opponent's hand. + /// The test harness (NodeNativeBattleHarness) feeds these directly; this method is the + /// live-session equivalent. + private void ShadowFeedServerFrames(IReadOnlyList routes) + { + if (!_engine.IsReady) return; + + foreach (var (target, frame, _) in routes) + { + switch (frame.Uri) + { + case NetworkBattleUri.Deal when !_engineDealFed: + _engineDealFed = true; + _log.LogWarning("BattleSession {Bid}: DEAL DIAG BEFORE: {Diag}", + BattleId, _engine.DiagnoseDealState()); + ShadowFeed(frame, isPlayerSeat: true, "Deal"); + _log.LogWarning("BattleSession {Bid}: DEAL DIAG AFTER: {Diag}", + BattleId, _engine.DiagnoseDealState()); + break; + + case NetworkBattleUri.Swap: + // The Swap RESPONSE (server-authored, carries post-mulligan self hand as + // pos→idx) must go to the engine for the correct seat. The client-sent Swap + // ({idxList}) also enters ShadowIngest but is harmless — its selfIdxList + // parses to null (no "self" key) so FirstMulliganOperation no-ops. + bool swapIsPlayer = ReferenceEquals(target, A); + ShadowFeed(frame, swapIsPlayer, $"SwapResponse({(swapIsPlayer ? "A" : "B")})"); + break; + + case NetworkBattleUri.Ready when !_engineReadyFed && ReferenceEquals(target, A): + _engineReadyFed = true; + // Feed A's Ready (carries A's idxChangeSeed → receiver seeds _selfXorShiftRandom). + ShadowFeed(frame, isPlayerSeat: false, "Ready"); + // Seed B's XorShift separately — A's Ready doesn't carry B's seed. + _engine.SeedOppoIdxChange(BattleSeeds.IdxChange(_state.MasterSeed, B.ViewerId)); + break; + } + } + } + + private void ShadowFeed(MsgEnvelope frame, bool isPlayerSeat, string label) + { + var engineFrame = frame.Body is RawBody ? frame : frame with { Body = ToRawBody(frame.Body) }; + var r = _engine.Receive(engineFrame, isPlayerSeat); + if (r.Diverged) + _log.LogWarning("BattleSession {Bid}: shadow engine diverged on {Label} feed: {Reason}", + BattleId, label, r.RejectReason); + if (frame.Uri is NetworkBattleUri.Deal or NetworkBattleUri.Swap or NetworkBattleUri.Ready) + LogEngineHandState(frame.Uri, $"ShadowFeed({label})"); + } + + private static readonly JsonSerializerOptions _bodyJsonOptions = Wire.WireJsonOptions.CamelCase; + + /// Convert a typed body record (DealBody, SwapResponseBody, ReadyBody, etc.) to the + /// the engine receiver expects. Serialize → JsonElement → ToObject (the + /// same deep-conversion MsgEnvelope.FromJson uses for incoming wire frames). + private static RawBody ToRawBody(IMsgBody? body) + { + if (body is null) return new RawBody(new Dictionary()); + var el = JsonSerializer.SerializeToElement(body, body.GetType(), _bodyJsonOptions); + var dict = el.EnumerateObject() + .ToDictionary(p => p.Name, p => MsgEnvelope.ToObject(p.Value)); + return new RawBody(dict); + } + /// Seat the shadow engine once, from the master seed + both deterministically-shuffled /// decks the node already computed (F-N-5). Attempted a single time; if the host can't seat the /// engine headless, it stays not-ready and the shadow no-ops for the rest of the battle. @@ -268,7 +361,16 @@ public sealed class BattleSession return; } _engineOwned = true; - _engine.Setup(_state.MasterSeed, + // Seed the engine's StableRandom with BattleSeeds.Stable(MasterSeed) — the SAME value the + // Matched frame ships to both clients (InitBattleHandler.cs:28). The clients seed their + // System.Random with Matched.seed (BattleManagerBase.cs:721), so the engine's stream must + // share that derivation to track. MasterSeed itself is a root only — every wire-facing seed + // (Stable, IdxChange, DeckShuffle) is a BattleSeeds.Derive(...) of it; the engine never + // consumes the root directly. Live regression: bid 654473755566 had MasterSeed=1184631275 + // and Stable=1543475792 (the Matched.seed); seeding the engine with the raw root made every + // turn-1+ draw pick a different deck position than the clients, so the opponent's first + // non-mulligan play addressed a card the engine never drew → HandCardToField threw. + _engine.Setup(BattleSeeds.Stable(_state.MasterSeed), _state.GetShuffledDeck(A), _state.GetShuffledDeck(B), (int)A.Context.ClassId, (int)B.Context.ClassId); } @@ -279,8 +381,21 @@ public sealed class BattleSession bool isPlayerSeat = ReferenceEquals(from, A); var r = _engine.Receive(env, isPlayerSeat); if (r.Diverged) - _log.LogInformation("BattleSession {Bid}: shadow engine diverged on {Uri}: {Reason}", + _log.LogWarning("BattleSession {Bid}: shadow engine diverged on {Uri}: {Reason}", BattleId, env.Uri, r.RejectReason); + if (env.Uri is NetworkBattleUri.Swap or NetworkBattleUri.TurnStart or NetworkBattleUri.PlayActions) + LogEngineHandState(env.Uri, $"ShadowIngest(seat={(isPlayerSeat ? "A" : "B")})"); + } + + private void LogEngineHandState(NetworkBattleUri uri, string label) + { + if (!_engine.IsReady) return; + var aIdxs = string.Join(",", Enumerable.Range(0, _engine.HandCount(true)) + .Select(i => _engine.HandCardIndex(true, i))); + var bIdxs = string.Join(",", Enumerable.Range(0, _engine.HandCount(false)) + .Select(i => _engine.HandCardIndex(false, i))); + _log.LogInformation("BattleSession {Bid}: engine hand after {Uri} {Label}: A=[{AHand}] B=[{BHand}]", + BattleId, uri, label, aIdxs, bIdxs); } } diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index e1dcde4..cf8f577 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -66,15 +66,43 @@ internal sealed class SessionBattleEngine public void Setup(int masterSeed, IReadOnlyList seatADeck, IReadOnlyList seatBDeck, int seatAClass = 1, int seatBClass = 2) + => SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng: null); + + /// TEST/DEBUG SEAM (Phase 4 Option-A viability PROBE — NOT a production fix). Identical to + /// but installs a logging + /// RNG source that, on EVERY StableRandom/StableRandomDouble roll, records a roll entry + /// (call index, API, the seat signals readable from mgr state at roll time, and the live call stack). + /// Lets a test answer: at roll time, is the ACTING SEAT determinable from mgr state alone, or only from + /// the stack? No production path calls this. + internal IReadOnlyList DebugSetupWithRollLog(int masterSeed, + IReadOnlyList seatADeck, IReadOnlyList seatBDeck, + int seatAClass = 1, int seatBClass = 2) + { + var log = new List(); + // The logger needs the mgr to read seat signals at roll time; the mgr is built inside Setup, so the + // logger reads it lazily via a closure populated right after construction. + HeadlessNetworkBattleMgr[] mgrBox = { null! }; + var rng = new RollLoggingRandomSource(new SeededRandomSource(masterSeed), log, () => mgrBox[0]); + SetupInternal(masterSeed, seatADeck, seatBDeck, seatAClass, seatBClass, rng, mgrBox); + return log; + } + + private void SetupInternal(int masterSeed, + IReadOnlyList seatADeck, IReadOnlyList seatBDeck, + int seatAClass, int seatBClass, + IRandomSource? rng, HeadlessNetworkBattleMgr[]? mgrBox = null) { // Prime the engine's process-global statics (CardMaster, Wizard.Data, all-8-class Master, // GameMgr/netUser/udid). Idempotent (process-once); makes the LIVE host ready so Setup succeeds // here rather than throwing into the shadow's no-op path (Phase 2 N2, carried-risk A). EngineGlobalInit.EnsureInitialized(); - // rng defaults to SeededRandomSource(masterSeed) inside the mgr — the stream is born aligned - // with the seed the node handed both clients (F-N-5; O-N-2 "bit-aligned anyway"). - var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed)); + // rng defaults to SeededRandomSource(masterSeed) inside the mgr — masterSeed here is the + // engine's StableRandom seed (parameter name preserved for API compatibility; callers pass + // BattleSeeds.Stable(rootMasterSeed) so the stream is born aligned with the seed the node + // ships to both clients in Matched.seed). F-N-5; O-N-2 "bit-aligned anyway". + var mgr = new HeadlessNetworkBattleMgr(new SessionContentsCreator(masterSeed), rng); + if (mgrBox is not null) mgrBox[0] = mgr; // publish for the test roll-logger closure (DebugSetupWithRollLog) // Recovery mode is the engine's OWN headless replay path: the live view/UI touches on the // receive cycle (BattleUIContainer.DisableMenu, turn-control UI, card-view creation, VFX // waits) are all gated `!IsRecovery` (BattleUIContainer.cs:130, BattleManagerBase.cs:1499+), @@ -93,15 +121,20 @@ internal sealed class SessionBattleEngine player.IsSelfTurn = true; enemy.IsSelfTurn = false; - // Seat the evolve points + evolve-wait-turn counters exactly as the real match-load's - // SetupInitialGameState -> SetupEvolCount does (BattleManagerBase.cs:1115/1132). The headless - // Setup builds the seats by hand and never runs SetupInitialGameState, so without this both seats' - // CurrentEpCount/EvolveWaitTurnCount stay at their field defaults (0/0) and CanEvolution always - // fails (CurrentEpCount - GetEp() < 0). doesPlayerGoFirst == false here: seat A (BattlePlayer) is - // the SECOND player (IsFirst defaults false; seat A's turn-1 draws 2), so it gets SECOND_PLAYER_EP - // (3) + EvolveWaitTurnCount 4, and seat B (BattleEnemy, first) gets FIRST_PLAYER_EP (2) + - // EvolveWaitTurnCount 5. TurnEvolveControl (run on each TurnStart receive) counts the wait down. - mgr.SetupEvolCount(doesPlayerGoFirst: false); + // Participant A always goes first (LoadedHandler gives A TurnState.First). The engine's + // BattlePlayer = isPlayer=true = seat A, so doesPlayerGoFirst must be true. This controls: + // (1) SetupEvolCount: first player gets FIRST_PLAYER_EP (2) + wait 5, + // second player gets SECOND_PLAYER_EP (3) + wait 4 + // (2) IsFirst → BattlePlayer.IsGameFirst / BattleEnemy.IsGameFirst → turn-1 draw count: + // first player draws 1, second draws 2 (BattlePlayerBase.TurnStartDrawCard) + mgr.IsFirst = true; + mgr.SetupEvolCount(doesPlayerGoFirst: true); + + // The real match-load's SetupInitialGameState(areCardsRandomlyDrawn:true) sets this flag + // (BattleManagerBase.cs:1110), routing LotteryRandomDrawCard through seeded StableRandom + // instead of top-of-deck. Without it the shadow draws DeckCardList[0] every time while + // clients draw seeded-random — desynchronizing the hand and every downstream field. + BattleManagerBase.IsRandomDraw = true; InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer @@ -136,6 +169,7 @@ internal sealed class SessionBattleEngine var dict = ToEngineDict((env.Body as RawBody)?.Entries); TranslateTargetOwners(dict, isPlayerSeat); + TranslateChoiceKeyAction(dict); var uri = MapUri(env.Uri); try @@ -210,6 +244,71 @@ internal sealed class SessionBattleEngine private const string TargetListKey = "targetList"; private const string VidKey = "vid"; private const string IsSelfKey = "isSelf"; + private const string KeyActionKey = "keyAction"; + private const string SelectCardKey = "selectCard"; + private const string CardIdKey = "cardId"; + + // --- live Choice-keyAction shape translation (live PvP ingest fidelity) ------------------------ + // + // THE GAP this closes: a Choice play's wire keyAction entry on the SENDER's send is the wrapped + // shape `{type:1, cardId:, selectCard:{cardId:[...], open:0|1}}` (verified + // in client-send captures, e.g. data_dumps/captures/battle_test/cl1/battle-traffic.ndjson live + // Resonance play). The engine's receive parser (NetworkBattleReceiver.cs:1202) reads the + // `selectCard` value through `ConvertToListInt`, which does `value as List` — a Dictionary + // value casts to null and the inner `foreach (... in null)` throws NRE. The whole + // ConvertReceiveDataToMakeData is wrapped in a swallow-catch (NetworkBattleReceiver.cs:1255-1260) + // that logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and returns false. + // SessionBattleEngine.Receive calls ReceivedMessage with checkBreakData:false, so the false isn't + // surfaced; the engine continues with `choiceIdList=[]`, the choice never resolves, and the played + // card never moves from hand to board. Then any LATER frame that addresses the un-resolved card + // by Index sees a stale hand entry — silently for a turn or two, until a TARGETED play looks for + // it on the board (where it should be per wire) and gets `null` from LookForActionDataToTargetCard + // → ActionProcessor.PlayCard:407 NRE on `selectedCard.SelfBattlePlayer`. + // + // OPPONENT-FACING relay shape is different: the node strips selectCard entirely from the opponent + // broadcast (verified: cl2 receives `keyAction:[{type:1, cardId:127011010}]`), so the opponent + // never needs this transform. Only the shadow engine — which ingests the SENDER's raw send — does. + // + // The fix: walk keyAction on the ENGINE's own dict copy (TranslateTargetOwners' pattern) and + // unwrap selectCard. `{cardId:[121011010], open:0}` → `[121011010]`. The `open` flag (was this + // choice revealed to the opponent) is irrelevant to the engine's resolution. The flat-list shape + // is what `ConvertToListInt` consumes successfully, AND what the existing test harness + // (NodeNativeBattleHarness.ChoicePlayBody) already supplies — that test passes, proving the rest + // of the Choice resolution path works given the right shape. Idempotent: an already-flat list + // (no wrapping dict) is left alone, so a future relay frame that happens to carry the flat form + // also resolves directly. + // + // Live regression: bid 131549100204, B's Resonance (127011010) play of idx 20 at error.txt:1642. + // Without the unwrap, idx 20 stays in B's hand; later A's 6-cost bounce targets B's "board" idx 20, + // engine can't find it on the board, ActionProcessor.PlayCard NRE's at the foreach over a list + // containing a null target. + private static void TranslateChoiceKeyAction(Dictionary dict) + { + if (!dict.TryGetValue(KeyActionKey, out var raw) || raw is not List entries) + return; + + foreach (var e in entries) + { + if (e is not Dictionary entry) continue; + if (!entry.TryGetValue(SelectCardKey, out var sel)) continue; + // Already-flat (a List): no transform needed. Idempotent guard. + if (sel is List) continue; + // Wrapped (a Dict): unwrap to the inner cardId list. + if (sel is Dictionary wrap + && wrap.TryGetValue(CardIdKey, out var inner) + && inner is List flat) + { + entry[SelectCardKey] = flat; + } + else + { + // Unrecognized shape — drop the key so the parse doesn't NRE; the play will resolve + // with an empty choice list, and the divergence (if any) will surface downstream + // rather than crash the receiver. + entry.Remove(SelectCardKey); + } + } + } // The decoded wire value may be a boxed long/int/bool depending on the codec; normalize to int. private static int ToInt(object v) => v switch @@ -457,6 +556,102 @@ internal sealed class SessionBattleEngine return card.Cost; } + // === TEST/DEBUG SEAMS (Phase 4 root-cause verification — NOT a production fix) ================= + // These exist solely to PROVE the post-mulligan reshuffle root cause from a test. They read/poke + // the engine's XorShift idx-change RNG, which the live recovery path leaves null/inactive (seed -1). + // No production code path calls them. Remove (or fold into the real seeding) when the fix lands. + + /// TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active? Mirrors the gate the + /// post-mulligan deck reshuffle/re-index checks (BattleMgr.XorShiftRandom(true) != null && + /// .IsActive, BattlePlayerBase.cs:3049/3073). Under the live recovery setup + /// (CreateXorShift(-1,-1) via NullRecoveryManager.IdxChangeSeed == -1) this is FALSE, so the + /// engine SKIPS the reshuffle the real clients performed. + internal bool SelfXorShiftActive => (_mgr?.XorShiftRandom(isSelf: true)?.IsActive) ?? false; + + /// TEST/DEBUG: same as for the OPPONENT seat. + internal bool OppoXorShiftActive => (_mgr?.XorShiftRandom(isSelf: false)?.IsActive) ?? false; + + /// DIAGNOSTIC: check if OnReceiveDeal is wired and report deck/hand counts. + internal string DiagnoseDealState() + { + if (_mgr is null) return "mgr=null"; + var or = _mgr.OperateReceive; + bool dealWired = or.OnReceiveDeal != null; + var p = _mgr.GetBattlePlayer(true); + var e = _mgr.GetBattlePlayer(false); + return $"OnReceiveDeal={(dealWired ? "wired" : "NULL")}, " + + $"playerDeck={p.DeckCardList.Count}, playerHand={p.HandCardList.Count}, " + + $"enemyDeck={e.DeckCardList.Count}, enemyHand={e.HandCardList.Count}"; + } + + /// Seed the opponent seat's XorShift for post-mulligan deck reshuffle. The Ready frame's + /// idxChangeSeed seeds the self seat (BattlePlayer/A) automatically via the receiver. The + /// opponent seat (BattleEnemy/B) needs its seed injected separately because the Ready frame sent + /// to A doesn't carry B's seed. Called from + /// after feeding the Ready. + internal void SeedOppoIdxChange(int oppoSeed) + { + _mgr?.CreateXorShift(-1, oppoSeed); + } + + /// TEST/DEBUG: inject BOTH per-seat idxChange seeds at once (the verification seam the + /// PostMulliganReshuffleRootCauseTests use). Production code uses the Ready frame for the self + /// seed + for the opponent seed. + internal void DebugSeedIdxChange(int selfSeed, int oppoSeed) + { + if (_mgr is null) throw new InvalidOperationException("DebugSeedIdxChange before Setup."); + _mgr.CreateXorShift(selfSeed, oppoSeed); + } + + /// TEST/DEBUG: override the engine's process-global BattleManagerBase.IsRandomDraw + /// flag. Production Setup now sets this true (matching the real match-load's + /// SetupInitialGameState(areCardsRandomlyDrawn:true)). This seam exists so tests can + /// force it false to reproduce the old top-of-deck bug. Static field → set per run under + /// [NonParallelizable]. + internal void DebugSetRandomDraw(bool value) => BattleManagerBase.IsRandomDraw = value; + + /// TEST/DEBUG (Phase 4 draw-recompute hypothesis): advance the SHARED _stableRandom + /// stream by draws, exactly as OperateReceive.StartOperate does on a + /// received frame carrying spin=n (OperateReceive.cs:80-83 loops StableRandomDouble() + /// n times). The live shadow never ingests the Ready frame that carries the wire spin, so its stream + /// is offset; this applies the pre-roll at the same point the real client would. + internal void DebugSpinPreroll(int n) + { + if (_mgr is null) throw new InvalidOperationException("DebugSpinPreroll before Setup."); + for (int i = 0; i < n; i++) _mgr.StableRandomDouble(); + } + + /// TEST/DEBUG: consume one value from the shared _stableRandom stream and return it. + /// Lets a regression test assert engine seed alignment against the wire — the very first + /// StableRandom.NextDouble() the engine produces must equal the first NextDouble() of a + /// fresh System.Random(BattleSeeds.Stable(masterSeed)), since clients seed + /// _stableRandom = new System.Random(Matched.seed) with the SAME value + /// (BattleManagerBase.cs:721; Matched.seed == BattleSeeds.Stable(masterSeed), + /// InitBattleHandler.cs:28). + internal double DebugStableRandomDouble() + { + if (_mgr is null) throw new InvalidOperationException("DebugStableRandomDouble before Setup."); + return _mgr.StableRandomDouble(); + } + + /// TEST/DEBUG: read the per-seat cardTotalNum counter that drives auto-assigned + /// Index for skill-generated tokens (BattleManagerBase.SetupCardIndex uses this when + /// addIndex == -1). After Setup it must equal deck.Count + 1 on both seats (matches + /// the real client's SBattleLoad.InitPlayer tail, SBattleLoad.cs:1292), so the FIRST + /// generated token gets Index 41 — clear of deck-loaded indices 1..40 — and matches the wire + /// add.idx. A stale value of 0 causes tokens to take Index 0, 1, ... and collide. + internal int DebugCardTotalNum(bool playerSeat) => + _mgr is null ? -1 : _mgr.GetBattlePlayer(playerSeat).cardTotalNum; + + /// TEST/DEBUG: the engine's running StableRandom/StableRandomDouble call count + /// (private BattleManagerBase.stableRandomCount), so a divergence dump can report how far the + /// shared stream has advanced at the moment of a mismatch. + internal int DebugStableRandomCount => + _mgr is null ? -1 + : (int)(typeof(BattleManagerBase) + .GetField("stableRandomCount", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(_mgr) ?? -1); + private engine::BattlePlayerBase Seat(bool playerSeat) => (_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat); @@ -598,7 +793,18 @@ internal sealed class SessionBattleEngine /// Seat one side's full deck in order (idx == list position + 1). Each card is created /// through the engine's own null-view seam and pushed via AddToDeck — the SeedDeck primitive the - /// test harness proved (HeadlessFixture.SeedDeck). + /// test harness proved (HeadlessFixture.SeedDeck). + /// Mirrors the real client's SBattleLoad.InitPlayer/InitEnemy tail: after + /// loading the 40-card deck at indices 1..40, set cardTotalNum = deck.Count + 1 so the + /// next skill-generated token gets Index 41 (matches the wire's add.idx). Without this, + /// cardTotalNum stays at the property default (0) and the auto-assign path + /// (SetupCardIndex(_, -1) in BattleManagerBase.cs:1770) hands tokens Index 0, 1, ..., + /// which COLLIDES with deck-loaded cards' Index 1..40. The collision is silent until something + /// plays the deck card with the colliding Index (e.g. Hoverboarder at deck idx 1 with a token + /// at engine Index 1): GetBattleCardIdx's SingleOrDefault finds two matches and + /// throws "Sequence contains more than one matching element". Also pin + /// BattleStartDeckCardList like the real client, so any skill that reads the starting + /// deck (e.g. tribe filters) sees the seeded deck instead of an empty list. private static void SeedDeck(BattleManagerBase mgr, IReadOnlyList deck, bool isPlayer) { BattlePlayerBase owner = mgr.GetBattlePlayer(isPlayer); @@ -607,6 +813,8 @@ internal sealed class SessionBattleEngine var card = CreateHeadlessCard(mgr, (int)deck[i], index: i + 1, isPlayer); owner.AddToDeck(card); } + owner.cardTotalNum = deck.Count + 1; + owner.BattleStartDeckCardList = new List(owner.DeckCardList); } private static readonly MethodInfo CreateCardWithoutResources = @@ -697,4 +905,79 @@ internal sealed class SessionBattleEngine t = t.BaseType; } } + + // === TEST/DEBUG: per-roll attribution probe (Phase 4 Option-A viability) ======================= + // Captures, at the EXACT moment of each StableRandom*/StableRandomDouble roll, the seat signals the + // mgr can read from its own state, plus the live call stack. The decisive question: can the acting + // seat be attributed from mgr STATE alone (a router could route on it), or only by reading the STACK? + + /// One recorded RNG roll. / + /// are the mgr-readable seat-turn flags at roll time; is the trimmed call + /// stack (the only place the acting seat is sometimes visible). + internal sealed record RollEntry( + int Index, string Api, int Arg, + bool SelfIsSelfTurn, bool OppoIsSelfTurn, + string Stack); + + // A logging IRandomSource: delegates to the real seeded source but records each roll. Reads the mgr's + // seat-turn flags (the richest seat signal a mgr-level StableRandom override can see — there is no + // "current operating seat" field on the mgr) and the call stack at the call site. + private sealed class RollLoggingRandomSource : IRandomSource + { + private readonly IRandomSource _inner; + private readonly List _log; + private readonly Func _mgr; + private int _i; + + public RollLoggingRandomSource(IRandomSource inner, List log, Func mgr) + { + _inner = inner; _log = log; _mgr = mgr; + } + + public double NextUnit() { Record("NextUnit", -1); return _inner.NextUnit(); } + public int NextSelf(int max) { Record("NextSelf", max); return _inner.NextSelf(max); } + + private void Record(string api, int arg) + { + bool selfTurn = false, oppoTurn = false; + try + { + var mgr = _mgr(); + if (mgr is not null) + { + selfTurn = mgr.GetBattlePlayer(true).IsSelfTurn; + oppoTurn = mgr.GetBattlePlayer(false).IsSelfTurn; + } + } + catch { /* read-only probe; never let a state read abort the roll */ } + + string stack = TrimStack(System.Environment.StackTrace); + _log.Add(new RollEntry(_i++, api, arg, selfTurn, oppoTurn, stack)); + } + + // Keep the frames that reveal WHO is rolling (mulligan lottery vs draw vs filter vs spin pre-roll), + // dropping the logger's own frames and System.Environment. + private static string TrimStack(string raw) + { + var lines = (raw ?? "").Split('\n') + .Select(s => s.Trim()) + .Where(s => s.Length > 0 + && !s.Contains("RollLoggingRandomSource") + && !s.Contains("Environment.get_StackTrace") + && !s.Contains("Environment.GetStackTrace")) + .Select(Shorten) + .Take(8); + return string.Join(" <- ", lines); + } + + // "at Namespace.Type.Method(args) in file:line N" -> "Type.Method" (keep it scannable). + private static string Shorten(string frame) + { + string s = frame.StartsWith("at ") ? frame.Substring(3) : frame; + int paren = s.IndexOf('('); + if (paren >= 0) s = s.Substring(0, paren); + var parts = s.Split('.'); + return parts.Length >= 2 ? parts[^2] + "." + parts[^1] : s; + } + } } diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index 7e3a79a..1fb80b9 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Sessions; @@ -169,7 +170,7 @@ public class HeadlessConductorTests Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - var ready = harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true); + var ready = harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false); Assert.That(ready.Accepted, Is.True, $"Ready rejected: {ready.RejectReason}"); // After Ready the mulligan is sealed and the main phase is entered, but no turn has been @@ -180,32 +181,29 @@ public class HeadlessConductorTests Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(0), "no turn opened yet after Ready"); // --- turn 1 (seat A active) ------------------------------------------------------------- - // Seat A is the engine's player seat and is NOT game-first here, so turn-1 draws TWO cards - // (the standard second-player turn-1 draw). PP ramps to 1. + // Seat A is game-first (doesPlayerGoFirst: true), so turn-1 draws ONE card. PP ramps to 1. var t1 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true); Assert.That(t1.Accepted, Is.True, $"turn1 TurnStart rejected: {t1.RejectReason}"); Assert.That(harness.Turn(playerSeat: true), Is.EqualTo(1), "seat A turn counter"); Assert.That(harness.Pp(playerSeat: true), Is.EqualTo(1), "turn 1 ramps seat A max PP to 1"); - Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(5), - "turn-1 second-player draw is 2 cards (3 -> 5)"); - Assert.That(harness.DeckCount(playerSeat: true), Is.EqualTo(25), "seat A deck after draw"); + Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(4), + "turn-1 first-player draw is 1 card (3 mulligan + 1 draw)"); + Assert.That(harness.DeckCount(playerSeat: true), Is.EqualTo(26), "seat A deck after draw"); // End seat A's turn. var t1End = harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true); Assert.That(t1End.Accepted, Is.True, $"turn1 TurnEnd rejected: {t1End.RejectReason}"); // --- turn 2 (seat B active) ------------------------------------------------------------- - // Seat B opens its first turn: PP ramps to 1 and it draws its turn-1 card. (Seat B's deck - // started full at 30 because its opening hand is dealt into hidden zones, not its - // HandCardList, until reveal — so its first visible draw moves deck 30 -> 29, hand 0 -> 1.) + // Seat B is second player (doesPlayerGoFirst: true → enemy goes second). Ready's + // isPlayerSeat=false triggers OperateOppoMulligan → DrawFirstMulliganCard, moving 3 dealt + // cards from deck to hand. Turn-1 draws 2 (second player draws 2 on turn 1). var t2 = harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false); Assert.That(t2.Accepted, Is.True, $"turn2 TurnStart rejected: {t2.RejectReason}"); Assert.That(harness.Turn(playerSeat: false), Is.EqualTo(1), "seat B turn counter"); Assert.That(harness.Pp(playerSeat: false), Is.EqualTo(1), "turn 2 ramps seat B max PP to 1"); - Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(1), "seat B turn-1 draw"); - // Seat B's opening hand was dealt into hidden zones (not HandCardList), so its deck started at 30; - // the single turn-1 draw brings it to 29. - Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(29), "seat B deck after turn-1 draw"); + Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(5), "seat B hand: 3 mulligan + 2 turn-1 draws"); + Assert.That(harness.DeckCount(playerSeat: false), Is.EqualTo(25), "seat B deck: 30 - 3 mulligan - 2 draws"); // Both leaders untouched (no damage dealt across the two opening turns) — state tracks // cleanly on BOTH seats at the turn boundary. @@ -213,6 +211,140 @@ public class HeadlessConductorTests Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life"); } + [Test] + public void Seat_A_play_after_partial_mulligan_finds_kept_card() + { + // Regression: a partial mulligan (swap 1 of 3) must leave the kept cards in hand. + // Matches live battle 175320039619: A (cl2, Forestcraft) swaps idx 1,2 (keeps 3). + // Includes BOTH client Swaps + server Swap responses (the full live frame stream). + var aDeck = new List { 101121080,102131020,100111010,102121030,101121020,101121110,101114010,100111010,102141010,102121010,101121020,102131030,701141011,100111020,101131050,100111020,100111070,101121010,100111070,101121080,100114010,101121110,101114050,101114050,100114010,100114010,102111060,113011010,102121030,102131010,100111020,101114050,101121080,101121010,101131020,113011010,113011010,101114010,102111060,102121010 }; + var bDeck = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList(); + using var harness = NodeNativeBattleHarness.Create( + seatADeck: aDeck, seatBDeck: bDeck, + seatAClass: CardClass.Forestcraft, seatBClass: CardClass.Runecraft); + + harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true); + + // Client Swap from A (idxList only — no "self") + harness.Push(NetworkBattleUri.Swap, new Dictionary { ["idxList"] = new List { 2, 1 } }, isPlayerSeat: true); + // Server Swap response to A + harness.Push(NetworkBattleUri.Swap, new Dictionary { ["self"] = PosIdxList((0, 4), (1, 5), (2, 3)) }, isPlayerSeat: true); + // Client Swap from B (no mulligan) + harness.Push(NetworkBattleUri.Swap, new Dictionary { ["idxList"] = new List() }, isPlayerSeat: false); + // Server Swap response to B + harness.Push(NetworkBattleUri.Swap, new Dictionary { ["self"] = PosIdxList((0, 1), (1, 2), (2, 3)) }, isPlayerSeat: false); + + // Ready (from A's perspective) + harness.Push(NetworkBattleUri.Ready, new Dictionary + { + ["self"] = PosIdxList((0, 4), (1, 5), (2, 3)), + ["oppo"] = PosIdxList((0, 1), (1, 2), (2, 4)), + ["idxChangeSeed"] = 1463392880, ["spin"] = 0, + }, isPlayerSeat: false); + + harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true); + + var handIdxs = Enumerable.Range(0, harness.HandCount(playerSeat: true)) + .Select(i => harness.HandCardIndex(playerSeat: true, i)).ToList(); + TestContext.WriteLine($"A hand after T1: [{string.Join(",", handIdxs)}]"); + Assert.That(handIdxs, Does.Contain(3), "kept card idx 3 must be in A hand"); + + var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(3), isPlayerSeat: true); + Assert.That(play.Accepted, Is.True, $"A play idx 3 rejected: {play.RejectReason}"); + Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "A board after play"); + } + + [Test] + public void Seed_deck_advances_cardTotalNum_so_tokens_dont_collide_with_deck_indices() + { + // Regression for the engine-divergence diagnosed 2026-06-07 (bid 806245601092). + // + // The real client's SBattleLoad.InitPlayer (SBattleLoad.cs:1292) loads the 40-card deck at + // indices 1..40 and THEN sets `cardTotalNum = deck.Count + 1` (== 41), so the first + // skill-generated token (via BattleManagerBase.SetupCardIndex with addIndex=-1) gets Index + // 41 — exactly what the wire `add.idx` carries (e.g. `{"add":{"idx":[41,42],...}}`). + // + // The headless SessionBattleEngine.SeedDeck used to omit that tail, leaving `cardTotalNum` + // at the property default (0). The first generated token then got Index 0, the second got + // Index 1, and they COLLIDED with deck-loaded cards at the same indices. The collision was + // silent until something addressed the deck card with the colliding Index: Hoverboarder at + // deck idx 1 made GetBattleCardIdx's SingleOrDefault find TWO Index-1 cards and throw + // "Sequence contains more than one matching element". + // + // The contract verified here: after Setup, `cardTotalNum` MUST equal `deck.Count + 1` on + // both seats. This pins SBattleLoad's tail behavior in the headless engine. + const int deckSize = 30; // NodeNativeBattleHarness.DefaultDeck is 30 cards + using var harness = NodeNativeBattleHarness.Create(); + Assert.That(harness.IsReady, Is.True, "engine seats headless"); + + Assert.Multiple(() => + { + Assert.That(harness.DebugCardTotalNum(playerSeat: true), Is.EqualTo(deckSize + 1), + "seat A cardTotalNum must be deck.Count+1 after Setup (= next token Index >= deck.Count+1)"); + Assert.That(harness.DebugCardTotalNum(playerSeat: false), Is.EqualTo(deckSize + 1), + "seat B cardTotalNum must be deck.Count+1 after Setup"); + }); + } + + [Test] + public void Engine_stableRandom_seed_aligns_with_wire_seed_clients_receive() + { + // Regression for the shadow-engine desync diagnosed 2026-06-07 (bid 654473755566). + // + // CLIENTS seed System.Random with Matched.seed (BattleManagerBase.cs:721), which the node + // sends as BattleSeeds.Stable(MasterSeed) (InitBattleHandler.cs:28). The engine must seed its + // _stableRandom with the SAME value; otherwise the very first NextDouble() returns a different + // number, every turn-1+ StableRandom-driven draw picks a different deck position, and the + // opponent's first non-mulligan play addresses a card the engine never drew → HandCardToField + // throws. + // + // Before the fix, engine.Setup received the raw MasterSeed (1184631275 in the live battle), + // while clients received BattleSeeds.Stable(MasterSeed) (=1543475792). After the fix, + // BattleSession.EnsureEngineSetup + NodeNativeBattleHarness.Create both pass the Stable-derived + // value, so both streams produce the same NextDouble sequence. + const int masterSeed = 1184631275; // the bid 654473755566 master seed + int wireSeed = BattleSeeds.Stable(masterSeed); + + // The first NextDouble a fresh client would consume (turn-1 first-player draw is the very + // first _stableRandom consumer — Deal/Swap/Ready don't touch _stableRandom). + double expectedFirstDouble = new System.Random(wireSeed).NextDouble(); + + using var harness = NodeNativeBattleHarness.Create(masterSeed: masterSeed); + Assert.That(harness.IsReady, Is.True, "engine seats headless"); + + double engineFirstDouble = harness.DebugStableRandomDouble(); + Assert.That(engineFirstDouble, Is.EqualTo(expectedFirstDouble), + $"engine _stableRandom must be seeded with BattleSeeds.Stable({masterSeed})={wireSeed} " + + "(the value Matched.seed ships clients); otherwise turn-1+ draws desync from the clients."); + } + + [Test] + public void Seat_B_vanilla_play_resolves_on_engine_state() + { + // Seat B (opponent/enemy) plays a vanilla follower on its first turn. Uses an all-vanilla + // deck so no spell-path interference. Verifies the doesPlayerGoFirst:true seat mapping + // lets B's play resolve through the engine (hand→board mutation). + var allVanilla = Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30).ToList(); + using var harness = NodeNativeBattleHarness.Create(seatADeck: allVanilla, seatBDeck: allVanilla); + + harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true); + harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true); + harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false); + + // A's turn + harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true); + harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true); + + // B's turn (second player, draws 2) + harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false); + Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(5), "B hand: 3 mulligan + 2 draws"); + + var bPlay = harness.Push(NetworkBattleUri.PlayActions, PlayBody(3), isPlayerSeat: false); + Assert.That(bPlay.Accepted, Is.True, $"B play rejected: {bPlay.RejectReason}"); + Assert.That(harness.HandCount(playerSeat: false), Is.EqualTo(4), "B hand after play"); + Assert.That(harness.BoardCount(playerSeat: false), Is.EqualTo(1), "B board after play"); + } + [Test] public void Opponent_reveal_seats_card_on_seat_B_headless() { @@ -229,7 +361,7 @@ public class HeadlessConductorTests Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -294,7 +426,7 @@ public class HeadlessConductorTests Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -335,7 +467,7 @@ public class HeadlessConductorTests // --- mulligan + open seat A turn 1 ------------------------------------------------------------ Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -391,7 +523,7 @@ public class HeadlessConductorTests // --- mulligan + seat A turn 1: play the 1/1 ------------------------------------------------- Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1"); Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "seat A 1/1 on board"); @@ -448,7 +580,7 @@ public class HeadlessConductorTests // --- mulligan + open seat A turn 1, play the vanilla onto seat A's board -------------------- Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, @@ -530,7 +662,7 @@ public class HeadlessConductorTests // --- mulligan + open seat A turn 1, end it (no enemy target yet) ----------------------------- Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); @@ -634,7 +766,7 @@ public class HeadlessConductorTests // seat A turn 1: play a 1/2 onto seat A's board. Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart (A)"); Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/2 (A)"); Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(1), "one seat A follower on board"); @@ -686,7 +818,7 @@ public class HeadlessConductorTests // seat A turn 1: play a 1/1. Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(1), isPlayerSeat: true).Accepted, Is.True, "turn1 play 1/1"); int attackerIdx = harness.InPlayCardIndex(playerSeat: true, boardPos: 0); @@ -729,7 +861,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0); @@ -767,6 +899,65 @@ public class HeadlessConductorTests "the UN-chosen token (A) must NOT be added — the engine resolved the specific chosen branch"); } + [Test] + public void Choice_play_resolves_under_wrapped_selectCard_wire_shape() + { + // Regression for the engine silently-dropped Choice play diagnosed 2026-06-07 + // (bid 131549100204): the SENDER's live wire wraps selectCard as + // selectCard:{cardId:[], open:0} + // (verified in data_dumps/captures/battle_test/cl1/battle-traffic.ndjson at the Resonance + // play of idx 20). The engine's receive parser reads selectCard via ConvertToListInt + // (NetworkBattleReceiver.cs:1202), which does `value as List` — a Dictionary value + // casts to null and the inner foreach NREs. The surrounding ConvertReceiveDataToMakeData has + // a swallow-all catch (NetworkBattleReceiver.cs:1255-1260) that logs to Debug.LogError + + // LocalLog — both shimmed/no-op'd headlessly — and returns false; SessionBattleEngine.Receive + // calls ReceivedMessage with checkBreakData:false, so the false isn't propagated. The play + // continues with choiceIdList=[], never moves the card from hand to board, and any LATER + // targeted play that addresses the un-resolved card by Index (e.g. a bounce spell) crashes + // with a null target. + // + // Fix: SessionBattleEngine.TranslateChoiceKeyAction unwraps the wrapped selectCard on the + // engine's own dict copy before the receiver sees it (sibling to TranslateTargetOwners). The + // unwrap is purely a shadow-ingest shape transformation — production engine code is + // unchanged, and the opponent-facing relay (which never carries selectCard at all) is + // untouched. After the unwrap, the same resolution path that the existing flat-list test + // (Choice_play_resolves_chosen_branch_on_engine_state_headless) exercises must produce the + // same outcome. + var seatADeck = Enumerable.Repeat(NodeNativeBattleHarness.ChoiceCardId, 30).ToList(); + using var harness = NodeNativeBattleHarness.Create(seatADeck: seatADeck); + + Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); + Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); + + int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0); + int ppBefore = harness.Pp(playerSeat: true); + const long chosen = NodeNativeBattleHarness.ChoiceTokenB; + + // Drive the play using the WRAPPED wire shape — the exact form a live client emits. + var play = harness.Push( + NetworkBattleUri.PlayActions, + NodeNativeBattleHarness.ChoicePlayBodyWrapped(choiceIdx, NodeNativeBattleHarness.ChoiceCardId, chosen), + isPlayerSeat: true); + + Assert.That(play.Accepted, Is.True, $"wrapped-selectCard choice play rejected: {play.RejectReason}"); + Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore), + "the choice card's cost must charge PP — confirms the play actually resolved, not silently dropped"); + + bool chosenInHand = false; + for (int i = 0; i < harness.HandCount(playerSeat: true); i++) + if (harness.HandCardId(playerSeat: true, i) == (int)chosen) { chosenInHand = true; break; } + Assert.That(chosenInHand, Is.True, + "the chosen token (B) must land in seat A's hand — proves the CHOSEN branch resolved through the wrapped wire shape"); + + bool otherInHand = false; + for (int i = 0; i < harness.HandCount(playerSeat: true); i++) + if (harness.HandCardId(playerSeat: true, i) == (int)NodeNativeBattleHarness.ChoiceTokenA) { otherInHand = true; break; } + Assert.That(otherInHand, Is.False, + "the UN-chosen token (A) must NOT be added — decisive that the unwrap forwarded the SPECIFIC chosen id, not a default or both"); + } + [Test] public void Deal_seats_three_card_hand_headless() { @@ -845,7 +1036,7 @@ public class HeadlessConductorTests Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -935,7 +1126,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -1049,7 +1240,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); // Ramp to seat A turn 3 (PP 3) so the cost-3 grantor is affordable. RampToSeatATurn(harness, targetTurn: 3); @@ -1110,7 +1301,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); RampToSeatATurn(harness, targetTurn: 3); int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId); @@ -1194,7 +1385,7 @@ public class HeadlessConductorTests // --- mulligan + open seat A turn 1, play a vanilla follower onto seat A's board ------------- Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -1295,7 +1486,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -1322,7 +1513,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -1347,7 +1538,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); @@ -1442,7 +1633,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnEnd"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True, "turn2 TurnStart (B)"); @@ -1477,7 +1668,7 @@ public class HeadlessConductorTests Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); - Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True, "turn1 TurnStart"); int choiceIdx = harness.HandCardIndex(playerSeat: true, handPos: 0); diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index 5d928a3..1b4279f 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -1,4 +1,5 @@ using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Lifecycle; using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions.Dispatch; @@ -219,7 +220,10 @@ internal sealed class NodeNativeBattleHarness : IDisposable var shuffledB = state.GetShuffledDeck(b); var engine = new SessionBattleEngine(); - engine.Setup(state.MasterSeed, shuffledA, shuffledB, + // Mirror BattleSession.EnsureEngineSetup: engine's StableRandom is seeded with + // BattleSeeds.Stable(MasterSeed), the value the Matched frame ships to clients + // (InitBattleHandler.cs:28). See BattleSession.cs for the full root-cause comment. + engine.Setup(BattleSeeds.Stable(state.MasterSeed), shuffledA, shuffledB, (int)a.Context.ClassId, (int)b.Context.ClassId); return new NodeNativeBattleHarness(state, a, b, engine, shuffledA, shuffledB); @@ -259,6 +263,17 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// The engine Index of the hand card at on the given seat. public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos); + /// TEST/DEBUG: pull one value from the engine's shared _stableRandom stream. Mirrors the + /// engine's seam; lets a regression test + /// assert seed alignment with the wire (clients seed their _stableRandom with the Matched.seed, + /// which is BattleSeeds.Stable(masterSeed)). + public double DebugStableRandomDouble() => Engine.DebugStableRandomDouble(); + + /// TEST/DEBUG: read the seat's auto-assign Index counter (cardTotalNum). After + /// Setup it must equal deck.Count + 1 so the next skill-generated token gets an Index + /// clear of the deck-loaded 1..40 (= the real client's SBattleLoad behavior). + public int DebugCardTotalNum(bool playerSeat) => Engine.DebugCardTotalNum(playerSeat); + /// The real wire CardId of the in-play follower at on the /// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity /// (M-HC-2). @@ -286,6 +301,19 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// Turns remaining until the seat may evolve (0 == unlocked) (M-HC-4b). public int EvolveWaitTurnCount(bool playerSeat) => Engine.EvolveWaitTurnCount(playerSeat); + // --- TEST/DEBUG seams (Phase 4 root-cause verification: post-mulligan reshuffle) --------------- + + /// TEST/DEBUG: is the engine's SELF-seat XorShift idx-change RNG active (the gate the + /// post-mulligan reshuffle checks)? Live recovery setup leaves it FALSE. + public bool SelfXorShiftActive => Engine.SelfXorShiftActive; + + /// TEST/DEBUG: opponent-seat XorShift active state. + public bool OppoXorShiftActive => Engine.OppoXorShiftActive; + + /// TEST/DEBUG: inject the per-seat idxChange seeds (call before the Ready mulligan-end frame + /// to activate the engine's own post-mulligan reshuffle). + public void DebugSeedIdxChange(int selfSeed, int oppoSeed) => Engine.DebugSeedIdxChange(selfSeed, oppoSeed); + /// Build an envelope for and ingest it into the engine for the /// given seat (player == seat A). Mirrors BattleNodeFlowTests.MakeEnvelopeWith + /// SessionBattleEngine.Receive. @@ -420,6 +448,36 @@ internal sealed class NodeNativeBattleHarness : IDisposable }, }; + /// VERBATIM CLIENT-SEND Choice play shape — the wrapped form + /// selectCard:{cardId:[<tokenId>], open:<0|1>} the sender's wire actually carries + /// (data_dumps/captures/battle_test/cl1/battle-traffic.ndjson, live bid 131549100204: + /// "selectCard":{"cardId":[121011010],"open":0}). The shadow engine's ingest receives this + /// wrapper directly (the node strips selectCard from the opponent broadcast, so opponent-facing + /// frames never see it); + /// unwraps it on the engine's own dict copy before the receiver parses keyAction. This driver + /// exists so a regression test can pin that unwrap end-to-end against the SAME shape the live + /// wire delivers, distinct from which fast-paths the flat list. + /// defaults to 0 (choice hidden from opponent) — the value the live + /// capture carries; flag is dropped by the unwrap and irrelevant to resolution. + public static Dictionary ChoicePlayBodyWrapped(int playIdx, long playedCardId, long chosenTokenId, int open = 0) => new() + { + ["playIdx"] = playIdx, + ["type"] = 30, + ["keyAction"] = new List + { + new Dictionary + { + ["type"] = "Choice", + ["cardId"] = playedCardId, + ["selectCard"] = new Dictionary + { + ["cardId"] = new List { chosenTokenId }, + ["open"] = open, + }, + }, + }, + }; + /// The engine's NetworkBattleDefine.PlayActionType.EVOLUTION opcode — confirmed /// = 20 in SVSim.BattleEngine/Engine/NetworkBattleDefine.cs (EVOLUTION_SELECT is 21). The /// receiver maps the wire type int straight to the enum; EVOLUTION/EVOLUTION_SELECT route through diff --git a/SVSim.UnitTests/BattleNode/Integration/PostMulliganReshuffleRootCauseTests.cs b/SVSim.UnitTests/BattleNode/Integration/PostMulliganReshuffleRootCauseTests.cs new file mode 100644 index 0000000..b0c172e --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Integration/PostMulliganReshuffleRootCauseTests.cs @@ -0,0 +1,198 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using SVSim.BattleNode.Protocol; + +namespace SVSim.UnitTests.BattleNode.Integration; + +/// +/// PHASE 4 STEP 1 — root-cause VERIFICATION (NOT a fix). Tier 1 mechanism proof for the reported PvP +/// spellboost:0 / "Target card was not found in hand cards" desync. +/// +/// THE CHAIN (already traced from engine source, re-confirmed here by behavior): +/// - The node runs the engine with mgr.IsRecovery = true. Under IsRecovery the engine seeds its +/// post-mulligan deck-reshuffle RNG from RecoveryManager.IdxChangeSeed +/// (NetworkBattleManagerBase.cs:259-261). The node's RecoveryManager is NullRecoveryManager, +/// whose IdxChangeSeed == -1, so the engine runs CreateXorShift(-1, -1). +/// - CreateXorShift only builds an XorShift when seed != -1 +/// (BattleManagerBase.cs:806-815), and new XorShift(-1) sets IsActive = false +/// (BattleManagerBase.cs:48). So both seats' XorShift stay null/inactive. +/// - The post-mulligan deck reshuffle + card re-index (AddToDeck gate at BattlePlayerBase.cs:3049 +/// queues returned cards; AddToDeckCardIndexChange at 3073-3084 repositions/renumbers them) is +/// gated on XorShiftRandom(...) != null && .IsActive && IsMulliganEnd. With the XorShift +/// inactive the engine SKIPS the reshuffle the real clients performed (each client used the per-seat +/// idxChangeSeed the node sent in its Ready frame: cl1=1430655717, cl2=661650374). +/// - Result: the engine's post-mulligan deck order + Index values diverge from the clients'. A client +/// play of (e.g.) idx8 finds no Index==8 card in the engine hand -> HandCardToField throws -> the +/// shadow logs "diverged"; downstream Played* reads fall back to 0 -> opponent sees spellboost:0. +/// +/// This file proves the MECHANISM headless and deterministically: +/// (1) the live-shaped setup leaves the XorShift inactive (reshuffle skipped); +/// (2) seeding it (the verification-only DebugSeedIdxChange hook) ACTIVATES the engine's OWN +/// reshuffle, changing the post-mulligan draw order/indices vs the un-seeded run. +/// +[TestFixture] +[NonParallelizable] +public class PostMulliganReshuffleRootCauseTests +{ + // --- frame bodies (same wire shapes the node emits; mirror HeadlessConductorTests) ------------- + + private static List PosIdxList(params (int pos, int idx)[] entries) + { + var list = new List(entries.Length); + foreach (var (pos, idx) in entries) + list.Add(new Dictionary { ["pos"] = pos, ["idx"] = idx }); + return list; + } + + // Opening deal: top 3 of each shuffled deck (idx 1,2,3). + private static Dictionary DealBody() => new() + { + ["self"] = PosIdxList((0, 1), (1, 2), (2, 3)), + ["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)), + }; + + // Mulligan AWAY the pos-2 card (deck idx 3) -> the server hands back the next unused deck idx (4). + // The mulliganed-away card returns to the deck; under an ACTIVE XorShift that return triggers the + // reshuffle/re-index. Under the live (inactive) setup it does not. + private static Dictionary SwapBody() => new() + { + ["self"] = PosIdxList((0, 1), (1, 2), (2, 4)), + }; + + // Ready seals the mulligan (sets IsMulliganEnd) and starts turn 1. + // + // CRUCIAL FIDELITY POINT (the live root cause): the real Ready frame is SERVER-AUTHORED and travels + // server->client (it is a "receive" in every capture; no client SEND frame carries idxChangeSeed). + // The live node's BattleSession.ShadowIngest feeds the engine ONLY inbound participant SENDS — so the + // shadow engine NEVER ingests the Ready frame, and the receiver's idxChangeSeed -> CreateXorShift path + // (NetworkBattleReceiver.cs:1125-1126) NEVER runs for the shadow. We model that here by carrying + // idxChangeSeed = -1 (the "engine never received a real seed" state), then optionally injecting the + // seed out-of-band via DebugSeedIdxChange to prove the seed is what drives the reshuffle. + private static Dictionary ReadyBody() => new() + { + ["self"] = PosIdxList((0, 1), (1, 2), (2, 4)), + ["oppo"] = PosIdxList((0, 1), (1, 2), (2, 3)), + ["idxChangeSeed"] = -1, + ["spin"] = 0, + }; + + private static Dictionary TurnStartBody() => new() { ["spin"] = 0 }; + + // The fresh smoke-capture per-seat idxChange seeds (battle 907324319325): cl1 = seat A self, + // cl2 = seat B. In the live recovery path only the SELF seed is consumed (oppIdxSeed = -1); we pass + // cl2 as the oppo seed here to also activate seat B's reshuffle for the symmetry check. + private const int Cl1SelfSeed = 1430655717; + private const int Cl2Seed = 661650374; + + /// Drive Deal + Swap + Ready + turn-1 TurnStart and return seat A's post-draw hand as + /// (Index, CardId) pairs in hand order. injects the idxChange seeds + /// BEFORE the mulligan ops (Swap/Ready), so the engine's own reshuffle is active when the abandoned + /// mulligan card is returned to the deck (MulliganCtrl._ReturnAbandonToDeck -> AddToDeck, whose + /// reshuffle gate checks XorShift active AT RETURN TIME) and re-indexed on the next TurnStart. + private static (List<(int Index, int CardId)> hand, bool selfActive, bool oppoActive, int deckCount) DriveToTurn1( + bool seedIdxChange) + { + // Deck with DISTINCT card identities across the first ~12 positions so a reshuffle is observable in + // CardId (not just Index). All ids are known-creatable headless (harness constants, sourced from the + // tk2 capture / engine tests). Position 30 is padded with the proven vanilla. + var distinctTop = new long[] + { + NodeNativeBattleHarness.VanillaFollowerId, // idx 1 + NodeNativeBattleHarness.AltVanillaFollowerId, // idx 2 + NodeNativeBattleHarness.VanillaOneOneFollowerId, // idx 3 + NodeNativeBattleHarness.HighLifeVanillaFollowerId, // idx 4 + NodeNativeBattleHarness.SpellboostCardId, // idx 5 + NodeNativeBattleHarness.SpellboostCardIdAlt, // idx 6 + NodeNativeBattleHarness.ClanTribeFollowerId, // idx 7 + NodeNativeBattleHarness.ChoiceCardId, // idx 8 + NodeNativeBattleHarness.BoardDependentCostCardId,// idx 9 + NodeNativeBattleHarness.ChoiceTokenA, // idx 10 + NodeNativeBattleHarness.ChoiceTokenB, // idx 11 + }; + var deck = new List(distinctTop); + deck.AddRange(Enumerable.Repeat(NodeNativeBattleHarness.VanillaFollowerId, 30 - deck.Count)); + + using var harness = NodeNativeBattleHarness.Create(seatADeck: deck, seatBDeck: deck); + + Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal"); + + // Seed BEFORE the mulligan ops so the XorShift is active when the abandoned card returns to deck. + if (seedIdxChange) + harness.DebugSeedIdxChange(Cl1SelfSeed, Cl2Seed); + + Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap"); + Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: false).Accepted, Is.True, "Ready"); + + bool selfActive = harness.SelfXorShiftActive; + bool oppoActive = harness.OppoXorShiftActive; + + Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, + Is.True, "turn1 TurnStart"); + + int handCount = harness.HandCount(playerSeat: true); + var hand = new List<(int, int)>(handCount); + for (int i = 0; i < handCount; i++) + hand.Add((harness.HandCardIndex(playerSeat: true, i), harness.HandCardId(playerSeat: true, i))); + + return (hand, selfActive, oppoActive, harness.DeckCount(playerSeat: true)); + } + + [Test] + public void Live_recovery_setup_leaves_XorShift_inactive_so_reshuffle_is_skipped() + { + // The harness seats the engine EXACTLY as BattleSession does (IsRecovery=true, no idxChange seed), + // so the XorShift must be inactive on BOTH seats — the live (broken) state. + var (handCurrent, selfActive, oppoActive, _) = DriveToTurn1(seedIdxChange: false); + + Assert.Multiple(() => + { + Assert.That(selfActive, Is.False, + "LIVE BUG: seat A XorShift inactive (CreateXorShift(-1,-1)) -> post-mulligan reshuffle SKIPPED"); + Assert.That(oppoActive, Is.False, "seat B XorShift also inactive"); + }); + + TestContext.WriteLine("UN-SEEDED (live) turn-1 hand (Index:CardId): " + + string.Join(", ", handCurrent.Select(h => $"{h.Index}:{h.CardId}"))); + } + + [Test] + public void Seeding_idxChange_flips_the_reshuffle_gate_from_inactive_to_active() + { + // BEFORE: live setup, XorShift inactive -> the reshuffle gate (XorShiftRandom().IsActive, the EXACT + // predicate BattlePlayerBase.cs:3049/3073 check) is CLOSED. + var (handUnseeded, selfActiveU, _, _) = DriveToTurn1(seedIdxChange: false); + // AFTER: inject the captured per-seat idxChange seeds -> the gate predicate is OPEN on both seats. + var (handSeeded, selfActiveS, oppoActiveS, _) = DriveToTurn1(seedIdxChange: true); + + TestContext.WriteLine("UN-SEEDED turn-1 hand (Index:CardId): " + + string.Join(", ", handUnseeded.Select(h => $"{h.Index}:{h.CardId}"))); + TestContext.WriteLine("SEEDED turn-1 hand (Index:CardId): " + + string.Join(", ", handSeeded.Select(h => $"{h.Index}:{h.CardId}"))); + + Assert.Multiple(() => + { + Assert.That(selfActiveU, Is.False, "un-seeded: seat A reshuffle gate CLOSED (XorShift inactive)"); + Assert.That(selfActiveS, Is.True, "seeded: seat A reshuffle gate OPEN (XorShift active)"); + Assert.That(oppoActiveS, Is.True, "seeded: seat B reshuffle gate OPEN (oppo seed != -1)"); + }); + + // HEADLESS-PATH NOTE (documented limitation, NOT a contradiction of the root cause): the engine's + // recovery mulligan path does not run the XorShift over the MULLIGAN cards. The reshuffle gate at + // BattlePlayerBase.cs:3049 also requires IsMulliganEnd, and on the recovery path + // (RecoveryOperationCollection.SecondMulliganOperation) the abandoned-card return (AddToDeck) runs + // BEFORE IsMulliganEnd is set, so the mulligan cards are never queued into AddToDeckList. The + // XorShift's GetChangeInt is consumed only by AddToDeckCardIndexChange (3079), i.e. cards added to + // the deck AFTER mulligan-end (mid-battle bounce/shuffle effects). So the seeded vs un-seeded turn-1 + // hand is IDENTICAL headless via this flow — the gate flips, but no mulligan card flows through the + // re-index headless. The end-to-end draw-divergence the seed drives is proven against the REAL wire + // in the Tier-2 capture-replay test (CaptureReplayReshuffleRootCauseTests), where the engine draws + // by its own (un-reshuffled) deck order while the capture's plays reference the client's + // (reshuffled) order -> the counted "not found in hand" divergences. We assert the headless + // invariance here so the limitation is pinned, not hidden. + Assert.That(handSeeded.Select(h => (h.Index, h.CardId)), + Is.EqualTo(handUnseeded.Select(h => (h.Index, h.CardId))), + "headless mulligan flow does not route mulligan cards through the XorShift re-index (see note) — " + + "the seed's draw effect is proven end-to-end in the Tier-2 capture-replay test"); + } +} diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs index 7ebd857..12c4d4c 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ServerBattleFramesTests.cs @@ -141,7 +141,7 @@ public class ServerBattleFramesTests var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000); var body = (ReadyBody)env.Body; Assert.That(body.IdxChangeSeed, Is.EqualTo(555_000)); - Assert.That(body.Spin, Is.EqualTo(243)); + Assert.That(body.Spin, Is.EqualTo(0)); Assert.That(body.Self[1].Idx, Is.EqualTo(4)); } diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs index 78352bc..13f1bd7 100644 --- a/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs +++ b/SVSim.UnitTests/BattleNode/Lifecycle/TypedBodyWireShapeTests.cs @@ -143,7 +143,7 @@ public class TypedBodyWireShapeTests var node = JsonNode.Parse(json)!.AsObject(); Assert.That(node["idxChangeSeed"]!.GetValue(), Is.EqualTo(771_335_280)); - Assert.That(node["spin"]!.GetValue(), Is.EqualTo(243)); + Assert.That(node["spin"]!.GetValue(), Is.EqualTo(0)); Assert.That(node["self"]!.AsArray().Count, Is.EqualTo(3)); Assert.That(node["oppo"]!.AsArray().Count, Is.EqualTo(3)); } diff --git a/SVSim.UnitTests/BattleNode/Protocol/Bodies/HandBodiesTests.cs b/SVSim.UnitTests/BattleNode/Protocol/Bodies/HandBodiesTests.cs index 54bdc2f..d45e2ed 100644 --- a/SVSim.UnitTests/BattleNode/Protocol/Bodies/HandBodiesTests.cs +++ b/SVSim.UnitTests/BattleNode/Protocol/Bodies/HandBodiesTests.cs @@ -44,12 +44,12 @@ public class HandBodiesTests Self: new[] { new PosIdx(0, 1) }, Oppo: new[] { new PosIdx(0, 1) }, IdxChangeSeed: 771_335_280, - Spin: 243); + Spin: 0); var node = (JsonObject)JsonSerializer.SerializeToNode(body)!; Assert.That(node["idxChangeSeed"]!.GetValue(), Is.EqualTo(771_335_280)); - Assert.That(node["spin"]!.GetValue(), Is.EqualTo(243)); + Assert.That(node["spin"]!.GetValue(), Is.EqualTo(0)); Assert.That(node["resultCode"]!.GetValue(), Is.EqualTo(1)); } }