From c3590e9c9b784131c55554524f17896aa15742a9 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 09:02:59 -0400 Subject: [PATCH] =?UTF-8?q?test(battle-engine-port):=20M10=20=E2=80=94=20f?= =?UTF-8?q?irst=20dynamic=20{}-value=20card=20resolves=20headless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DynamicValueSpellOracleTests proves the engine COMPUTES an effect magnitude from live game state (the value the wire can't carry). Card 112134010's `when_play damage={me.play_count}-1` resolves via the proven IsForecast/ IsRecovery + ActionProcessor.PlayCard (DP4) path; the oracle asserts the damage equals the engine's own live GetCurrentTurnPlayCount() - 1, not a literal. Seeds play_count via M4's AddCurrentTrunPlayCount seam; lone surviving enemy 13/13 gives a clean life-delta; selectedCards: null (auto-target AoE). 10/10 green; zero Engine/shim/manifest changes; drift clean. First-unknown resolved by the first RED: the per-play +1 lives in OnBeforePlayCard (wired only via OperateMgr/Prediction), so the direct- ActionProcessor harness reads exactly the seeded count (damage == seeded-1); load-bearing proven by varying the seed 4->7 and watching damage track 3->6. Co-Authored-By: Claude Opus 4.8 --- .../DynamicValueSpellOracleTests.cs | 137 ++++++++++++++++++ SVSim.BattleEngine.Tests/HeadlessFixture.cs | 37 ++++- 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 SVSim.BattleEngine.Tests/DynamicValueSpellOracleTests.cs diff --git a/SVSim.BattleEngine.Tests/DynamicValueSpellOracleTests.cs b/SVSim.BattleEngine.Tests/DynamicValueSpellOracleTests.cs new file mode 100644 index 0000000..039b3df --- /dev/null +++ b/SVSim.BattleEngine.Tests/DynamicValueSpellOracleTests.cs @@ -0,0 +1,137 @@ +using System.Reflection; +using NUnit.Framework; +using Wizard; +using Wizard.Battle; + +namespace SVSim.BattleEngine.Tests +{ + // M10 (the first DYNAMIC `{}`-VALUE card — the first deliberate step beyond the four §5-named + // oracle dimensions M2-M9 closed): a when_play spell whose effect MAGNITUDE is COMPUTED by the + // engine from live game state, not carried as a literal. 112134010's sole skill is + // `when_play damage={me.play_count}-1` to units; the `{}` resolves + // (SkillOptionValue.ParseInt -> SkillFilterVariable.Parse -> SkillEnvironmentalPlayCount.Filtering) + // to `GetCurrentTurnPlayCount() - 1`. That GetCurrentTurnPlayCount() is the SAME per-turn counter + // M4 seeded via the public AddCurrentTrunPlayCount to drive a play_count GATE — M10 proves the seam + // also feeds the effect VALUE. + // + // The new oracle dimension over every prior milestone is the ENGINE-COMPUTED VALUE: the asserted + // damage is derived from the engine's OWN live play-count accessor (GetCurrentTurnPlayCount() - 1), + // never a hardcoded literal. Per memory project_battle_relay_nontargeted_effects, a state-derived + // value that the wire could NOT carry (spellboost cost) is exactly what desynced the PvP relay; + // proving the engine resolves a `{}` value headless is the direct validation that the port (not a + // relay) is the necessary path. + // + // Timing note (the M10 first-unknown, RESOLVED empirically by the first RED): the per-play + // auto-increment AddCurrentTrunPlayCount(1) lives in ActionProcessor's OnBeforePlayCard + // (BattlePlayerBase.cs:1400), which is subscribed by SetupActionProcessorEvent — and that is only + // called on the OperateMgr / Prediction / OperationSimulator paths, NOT on the direct + // `new ActionProcessor(pair).PlayCard` (DP4) path this harness uses. So the headless play does NOT + // self-bump the per-turn play count: the skill reads EXACTLY the seeded GetCurrentTurnPlayCount() + // and the damage == seeded - 1. (The first RED expected a +1 that this path never applies; the + // state-derived primary assertion below was right regardless, and the concrete pins were corrected + // to the observed no-bump behavior.) + [TestFixture] + public class DynamicValueSpellOracleTests + { + 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); + } + + [Test] + public void Dynamic_damage_spell_deals_engine_computed_play_count_value() + { + HeadlessEngineEnv.EnsureInitialized(); + 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 (see M2-M6 oracles): opponent refs + active turn flag. The + // spell's target resolver walks player -> opponent -> opponent's in-play units; the + // `{me.play_count}` read keys on the active player's current turn. + 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 state (blocks plays, M3). + HeadlessEngineEnv.InitLeaderLife(mgr); + + // Put ONE vanilla follower on the ENEMY board. The spell is `character=both` (AoE over both + // boards' units), but with no player-side units the only matched target is this enemy + // follower; its base life (13) exceeds any seeded play count so it SURVIVES -> clean + // life-delta read (no dependence on death/removal). card_type=unit excludes both leaders. + var target = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.DynamicDamageTargetFollowerId, 0, isPlayer: false); + + // Seed the live game state the `{}` value reads: the active player's current-turn play + // count. This is the M4 seam (AddCurrentTrunPlayCount), here driving the VALUE not a gate. + player.AddCurrentTrunPlayCount(HeadlessEngineEnv.DynamicSeededPlayCount); + + var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.DynamicDamageSpellId); + + // Place the dynamic-value spell in the active player's hand with PP to spare. + var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.DynamicDamageSpellId, 1, isPlayer: true, mgr); + player.HandCardList.Add(card); + player.Pp = 10; + + // Pre-state snapshot. + int ppBefore = player.Pp; + int handBefore = player.HandCardList.Count; + int playerInplayBefore = player.ClassAndInPlayCardList.Count; + int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count; + int targetLifeBefore = target.Life; + int playerLeaderLifeBefore = player.ClassAndInPlayCardList[0].Life; + int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life; + + // Resolve the play through the real engine (auto-targeted AoE -> selectedCards: null). + var pair = mgr.GetBattlePlayerPair(isPlayer: true); + var ap = new ActionProcessor(pair); + Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null), + "ActionProcessor.PlayCard threw on a dynamic {}-value damage spell"); + + // The engine-computed value, derived from the engine's OWN live play-count accessor (the + // direct-ActionProcessor path does not self-bump it, so this reads the seeded value) — + // exactly the value the skill's `{me.play_count}-1` resolved against. NOT a hardcoded + // literal: this is the M10 dimension (effect magnitude computed from state the wire can't + // carry). + int playCountAtResolution = player.GetCurrentTurnPlayCount(); + int expectedDamage = playCountAtResolution - 1; + int actualDamage = targetLifeBefore - target.Life; + + Assert.Multiple(() => + { + // PRIMARY M10 assertion: the damage dealt equals the engine-COMPUTED {me.play_count}-1, + // read from live state — proving the engine resolved the `{}` expression, not a literal. + Assert.That(actualDamage, Is.EqualTo(expectedDamage), + "damage dealt did not equal the engine-computed {me.play_count}-1 value"); + // Concrete pins (catch a silent state-read failure where play_count would default to 0, + // making damage -1 -> 0): the direct-ActionProcessor path applies no self-play bump, so + // the resolution-time count is exactly the seeded value and the damage is seeded - 1. + Assert.That(playCountAtResolution, Is.EqualTo(HeadlessEngineEnv.DynamicSeededPlayCount), + "play count was not read as the seeded current-turn value"); + Assert.That(actualDamage, Is.EqualTo(HeadlessEngineEnv.DynamicSeededPlayCount - 1), + "net damage did not equal seeded play_count - 1 ({me.play_count}-1 mis-resolved)"); + // Target survives (life > damage) and stays on the board; both leaders untouched + // (card_type=unit excludes class cards). + Assert.That(target.Life, Is.EqualTo(targetLifeBefore - expectedDamage), "target life delta wrong"); + Assert.That(enemy.ClassAndInPlayCardList, Does.Contain(target), "target unexpectedly left the board"); + Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "enemy board count changed"); + Assert.That(player.ClassAndInPlayCardList[0].Life, Is.EqualTo(playerLeaderLifeBefore), "player leader damaged (unit-only AoE hit a leader)"); + Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), "enemy leader damaged (unit-only AoE hit a leader)"); + // §5 spell-shaped invariants: cost paid, spell leaves hand, does NOT occupy the board. + Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost"); + Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand"); + Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1"); + Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board"); + Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed"); + }); + } + } +} diff --git a/SVSim.BattleEngine.Tests/HeadlessFixture.cs b/SVSim.BattleEngine.Tests/HeadlessFixture.cs index dc0c8ab..3bb8d7b 100644 --- a/SVSim.BattleEngine.Tests/HeadlessFixture.cs +++ b/SVSim.BattleEngine.Tests/HeadlessFixture.cs @@ -116,6 +116,40 @@ namespace SVSim.BattleEngine.Tests public const int DrawSpellId = 800114010; public const int DeckSeedCardId = FollowerId; // the single known deck card (neutral 1/2 vanilla) + // M10 next milestone: the first DYNAMIC `{}`-VALUE card — proves the engine COMPUTES an effect + // magnitude from live game state (a value the wire can't carry; per memory + // project_battle_relay_nontargeted_effects this state-derived-value problem is exactly what + // broke the PvP relay, so proving the engine resolves it headless is the direct validation that + // the port — not a relay — is the necessary path). Still non-RNG: a seeded state makes the value + // deterministic. 112134010 is an ELF (clan 1) cost-2 SPELL whose sole skill is `when_play` + // `damage={me.play_count}-1` to `character=both&target=inplay&card_type=unit` (with a + // `base_card_id!=900111010|900111020` exclusion) — an AoE over BOTH boards' units, auto-targeted + // (no select_count, so selectedCards: null like M2-M5), ungated (skill_condition=character=me). + // + // The `{}` value resolves (SkillOptionValue.ParseInt) as + // `_filterVariable.Parse("me.play_count") - 1`, where Parse routes to + // SkillEnvironmentalPlayCount.Filtering -> playerInfo.GetCurrentTurnPlayCount() (the + // `isPrePlay=false` resolution path). That is the SAME per-turn counter the public + // AddCurrentTrunPlayCount feeds (M4 proved this seam drove the play_count>2 GATE; M10 proves it + // also feeds the `{}` VALUE). The per-play auto-increment AddCurrentTrunPlayCount(1) lives in + // ActionProcessor's OnBeforePlayCard (BattlePlayerBase.cs:1400), subscribed by + // SetupActionProcessorEvent — which is ONLY called on the OperateMgr/Prediction/OperationSimulator + // paths, NOT on the direct `new ActionProcessor(pair).PlayCard` (DP4) path this harness uses. So + // the headless play does NOT self-bump the per-turn count: the skill reads EXACTLY the seeded + // GetCurrentTurnPlayCount() and the damage == seeded - 1. The oracle derives the expected + // magnitude from the engine's OWN live GetCurrentTurnPlayCount(), not from a hardcoded literal, + // which is the M10 dimension (engine-computed value, not a wire-carried constant). + // + // The target is the M6 vanilla NEUTRAL 13/13 follower (SelectTargetFollowerId, already loaded): + // life 13 > any reasonable seeded count, so it SURVIVES for a clean life-delta read (reusing the + // M3/M6/M8 damage->life path), and `card_type=unit` excludes both leaders (asserted untouched). + public const int DynamicDamageSpellId = 112134010; + public const int DynamicDamageTargetFollowerId = SelectTargetFollowerId; // neutral 13/13 (survives, clean delta) + // A deliberately non-trivial seeded per-turn play count so the computed damage (== this value) + // is an obvious state read, not a coincidence with a small literal. The load-bearing probe + // (M4/M6/M8 discipline) varies this and watches the damage track it. + public const int DynamicSeededPlayCount = 4; + private static bool _done; public static void EnsureInitialized() @@ -136,7 +170,8 @@ namespace SVSim.BattleEngine.Tests // real stats. The summoned token id must be present: Skill_summon_token resolves it // through CardMaster.GetCardParameterFromId during creation. HeadlessCardMaster.Load(FollowerId, SpellId, BuffFollowerId, TokenSpellId, SummonedTokenId, - TargetSpellId, SelectTargetFollowerId, UnselectTargetFollowerId, DestroySpellId, DrawSpellId); + TargetSpellId, SelectTargetFollowerId, UnselectTargetFollowerId, DestroySpellId, DrawSpellId, + DynamicDamageSpellId); // Master reference data (class-character list) for leader/class card resolution. HeadlessMasterData.Install(); // Player/enemy leaders (chara ids must map to a ClassCharacterMasterData in Master).