From 7370a35e9c92aac59de7e983a14439447d299408 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 09:10:45 -0400 Subject: [PATCH] =?UTF-8?q?test(battle-engine-port):=20M11=20=E2=80=94=20g?= =?UTF-8?q?ated=20conditional=20resolves=20headless=20(the=20GATE=20is=20t?= =?UTF-8?q?he=20oracle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card 103111050 (ELF cost-1 self-buff follower) carries skill_condition `character=me&target=self&play_count>2`. New GatedConditionalOracleTests asserts BOTH branches of the SAME card in one fixture, varying only the seeded per-turn play count via the public AddCurrentTrunPlayCount seam (M4/M10): * gate TRUE (seed 5 > 2) -> when_play powerup fires -> 1/1 -> 2/2 * gate FALSE (seed 0 <= 2) -> powerup is a NO-OP (stays 1/1), BUT the card still pays its cost and still moves hand -> board. This proves the engine SUPPRESSES an effect when a skill_condition is false (the dual of "effect fires" — no prior milestone proved this), and that the gate suppresses the EFFECT, not the PLAY. Jointly satisfiable only by a correctly- gating engine: an always-buffs engine fails FALSE, a never-buffs engine fails TRUE. Reuses the M4-proven buff dimension so the only new thing under test is the conditional itself. 11/11 green; engine 0 errors; check_drift clean; ZERO new Engine copies / ZERO shim / ZERO manifest changes — a clean milestone like M4/M6/M8/M10 (condition evaluation is pure logic on copied engine code). Load-bearing proof (M4/M6/M8/M10 discipline; the test passed on its first run, which proves nothing alone): swapped the two seeds -> exactly the 4 stat assertions failed for the right reason (formerly-TRUE branch seeded 0 took no buff [1/1, expected 2/2]; formerly-FALSE branch seeded 5 buffed [2/2, expected 1/1]), while the cost-paid + hand->board assertions stayed green in both branches — confirming the gate drives ONLY the effect and the card resolves regardless. Reverted -> 11/11. Co-Authored-By: Claude Opus 4.8 --- .../GatedConditionalOracleTests.cs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 SVSim.BattleEngine.Tests/GatedConditionalOracleTests.cs diff --git a/SVSim.BattleEngine.Tests/GatedConditionalOracleTests.cs b/SVSim.BattleEngine.Tests/GatedConditionalOracleTests.cs new file mode 100644 index 0000000..4cb6aa6 --- /dev/null +++ b/SVSim.BattleEngine.Tests/GatedConditionalOracleTests.cs @@ -0,0 +1,147 @@ +using System.Reflection; +using NUnit.Framework; +using Wizard; +using Wizard.Battle; + +namespace SVSim.BattleEngine.Tests +{ + // M11 (the GATE itself is the oracle): every prior milestone either had no skill_condition or + // seeded its gate TRUE so the effect fires (M4 seeded play_count>2; M10 seeded a play_count + // VALUE). None proved the engine SUPPRESSES an effect when a skill_condition evaluates FALSE — + // the dual of "effect fires". M11 proves conditional BRANCHING resolves headless by asserting + // BOTH directions of the SAME gated card in ONE fixture (design "M11 — NEXT" resume guide): + // + // * gate TRUE (play_count > 2, seeded via the public AddCurrentTrunPlayCount seam M4/M10 use) + // -> the when_play powerup fires -> the follower is buffed over its base stats. + // * gate FALSE (play_count <= 2, the bare-construction default) + // -> the powerup is a NO-OP: zero stat delta, BUT the card still pays its cost + // and still leaves hand -> board (the gate suppresses the EFFECT, not the PLAY). + // + // Card: 103111050 — the M4 self-buff follower (ELF clan-1 cost-1 base 1/1, sole non-evo skill + // `when_play` `powerup` `add_offense=1&add_life=1` to `character=me&target=self`), whose + // skill_condition is `character=me&target=self&play_count>2` (verified in cards.json). The gate + // reads BattlePlayerBase.GetCurrentTurnPlayCount(), seedable past/below the threshold via the + // public AddCurrentTrunPlayCount. Reusing the M4-proven buff DIMENSION means the only NEW thing + // under test is the CONDITIONAL — exactly the resume-guide's "proven effect dimension, gate is + // the oracle" prescription. + // + // Why one fixture, both branches, ONE card is decisive: the two assertions are jointly + // satisfiable ONLY by a correctly-gating engine. An "always-buffs" engine fails the FALSE branch + // (would buff with play_count=0); a "never-buffs" engine fails the TRUE branch (M4's gate seed + // wouldn't fire). M4 already demonstrated this split as a manual load-bearing probe (remove the + // seed -> buff vanishes); M11 promotes it to the PRIMARY assertion. + [TestFixture] + public class GatedConditionalOracleTests + { + // A clearly super-threshold seed (play_count 5 > 2): the gate evaluates TRUE, fanfare fires. + private const int GateTrueSeed = 5; + // The bare-construction default is play_count 0 (<= 2 -> gate FALSE); we seed nothing for the + // FALSE branch, exactly as M4's load-bearing probe did when it removed its seed. + private const int GateFalseSeed = 0; + + private static void SetPrivateField(object obj, string name, object value) + { + var t = obj.GetType(); + var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); } + Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}"); + f.SetValue(obj, value); + } + + // Resolve the gated self-buff follower on a FRESH battle with the per-turn play count seeded + // to `seededPlayCount`, and report the play's outcome. A fresh mgr per branch is required: + // play_count is per-mgr state and a resolved play mutates the board, so the two branches must + // not share a battle. Mirrors the M4 BuffFollowerOracleTests setup verbatim, parameterized on + // the seed (which is the only thing M11 varies between branches). + private static (BattleCardBase card, CardParameter param, int ppBefore, int ppAfter, + int handBefore, bool inHandAfter, int inplayBefore, bool onBoardAfter, int inplayAfter) + PlayGatedSelfBuff(int seededPlayCount) + { + BattleManagerBase.IsForecast = true; // suppress VFX registration (F1) + var mgr = new SingleBattleMgr(new HeadlessContentsCreator()); + mgr.IsRecovery = true; // collapse wait delays to 0 (F1) + + var player = mgr.BattlePlayer; + var enemy = mgr.BattleEnemy; + + // Minimal opponent/turn wiring (M2/M3/M4 oracles): opponent refs + active turn flag. The + // self-buff target resolver (`character=me&target=self`) reads the active player's own + // in-play card, so the turn flag must be set before the fanfare sweeps. + SetPrivateField(player, "_opponentBattlePlayer", enemy); + SetPrivateField(enemy, "_opponentBattlePlayer", player); + player.IsSelfTurn = true; + enemy.IsSelfTurn = false; + + // Seed leader life so neither leader reads as a 0-life game-over that silently blocks the + // play (M3 learning). This card deals no damage but the play-legality gate still checks it. + HeadlessEngineEnv.InitLeaderLife(mgr); + + // THE GATE SEED — the one knob M11 turns between branches. The skill_condition + // `play_count>2` reads BattlePlayerBase.GetCurrentTurnPlayCount(); seed it via the public + // AddCurrentTrunPlayCount (M4/M10 seam). For the FALSE branch we leave the bare default 0. + if (seededPlayCount > 0) player.AddCurrentTrunPlayCount(seededPlayCount); + + var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.BuffFollowerId); + + // Place the gated self-buff follower in the active player's hand with PP to spare; empty board. + var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.BuffFollowerId, 1, isPlayer: true, mgr); + player.HandCardList.Add(card); + player.Pp = 10; + + int ppBefore = player.Pp; + int handBefore = player.HandCardList.Count; + int inplayBefore = player.ClassAndInPlayCardList.Count; + + var pair = mgr.GetBattlePlayerPair(isPlayer: true); + var ap = new ActionProcessor(pair); + Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null), + $"ActionProcessor.PlayCard threw on the gated self-buff (seed={seededPlayCount})"); + + return (card, cardParam, ppBefore, player.Pp, + handBefore, player.HandCardList.Contains(card), + inplayBefore, player.ClassAndInPlayCardList.Contains(card), + player.ClassAndInPlayCardList.Count); + } + + [Test] + public void Gated_fanfare_fires_when_seeded_true_and_is_suppressed_when_false() + { + HeadlessEngineEnv.EnsureInitialized(); + + // ----- Branch 1: gate TRUE (play_count 5 > 2) -> the fanfare FIRES (M4 dimension). ----- + var t = PlayGatedSelfBuff(GateTrueSeed); + + // ----- Branch 2: gate FALSE (play_count 0 <= 2) -> the fanfare is SUPPRESSED. ----- + var f = PlayGatedSelfBuff(GateFalseSeed); + + Assert.Multiple(() => + { + // PRIMARY M11 assertion — the gate itself: SAME card, opposite stat outcomes driven + // ONLY by the seeded condition. + // TRUE -> buffed: base 1/1 + 1/1 = 2/2. + Assert.That(t.card.Atk, Is.EqualTo(t.param.Atk + HeadlessEngineEnv.BuffAddOffense), + "[gate TRUE] atk != base + add_offense (fanfare should have fired)"); + Assert.That(t.card.Life, Is.EqualTo(t.param.Life + HeadlessEngineEnv.BuffAddLife), + "[gate TRUE] life != base + add_life (fanfare should have fired)"); + // FALSE -> unbuffed: stays at the CardCSVData base 1/1 (effect suppressed). + Assert.That(f.card.Atk, Is.EqualTo(f.param.Atk), + "[gate FALSE] atk != base (fanfare should have been gated out)"); + Assert.That(f.card.Life, Is.EqualTo(f.param.Life), + "[gate FALSE] life != base (fanfare should have been gated out)"); + + // The gate suppresses the EFFECT, not the PLAY: in BOTH branches the card still pays + // its cost and still moves hand -> board like any follower. + // TRUE branch: + Assert.That(t.ppAfter, Is.EqualTo(t.ppBefore - t.param.Cost), "[gate TRUE] PP not reduced by cost"); + Assert.That(t.inHandAfter, Is.False, "[gate TRUE] card still in hand"); + Assert.That(t.onBoardAfter, Is.True, "[gate TRUE] card not on board"); + Assert.That(t.inplayAfter, Is.EqualTo(t.inplayBefore + 1), "[gate TRUE] in-play count not +1"); + // FALSE branch — the M11 crux: cost STILL paid + card STILL resolves despite the no-op effect. + Assert.That(f.ppAfter, Is.EqualTo(f.ppBefore - f.param.Cost), "[gate FALSE] PP not reduced by cost"); + Assert.That(f.inHandAfter, Is.False, "[gate FALSE] card still in hand"); + Assert.That(f.onBoardAfter, Is.True, "[gate FALSE] card not on board"); + Assert.That(f.inplayAfter, Is.EqualTo(f.inplayBefore + 1), "[gate FALSE] in-play count not +1"); + }); + } + } +}