test(battle-engine-port): M4 COMPLETE — when_play self-buff follower resolves headless (4/4 green)
Fold SetupCardEvent into a shared HeadlessEngineEnv.CreateHeadlessHandCard primitive (consolidating the duplicated M2/M3 helpers), then add the M4 oracle: card 103111050 (ELF cost-1 1/1, when_play powerup add_offense=1&add_life=1 to target=self). New oracle dimension = the played card's OWN stat delta (1/1 -> 2/2). Gate play_count>2 seeded via the public AddCurrentTrunPlayCount; proven load-bearing (without the seed the fanfare gates out and Atk stays 1). No new shim/data gaps were needed — only harness seeding. Engine still 0 errors; check_drift clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
100
SVSim.BattleEngine.Tests/BuffFollowerOracleTests.cs
Normal file
100
SVSim.BattleEngine.Tests/BuffFollowerOracleTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using Wizard;
|
||||
using Wizard.Battle;
|
||||
|
||||
namespace SVSim.BattleEngine.Tests
|
||||
{
|
||||
// M4 (next-hardest deterministic card): a when_play SELF-BUFF follower resolves to correct
|
||||
// authoritative state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the M2
|
||||
// vanilla follower and M3 fixed-damage spell proved (design §5 / DP4 + M3 resume recipe). The new
|
||||
// oracle dimension over M2/M3 is the PLAYED CARD'S OWN STAT DELTA: the fanfare `powerup`
|
||||
// `add_offense=1&add_life=1` to `target=self` must raise the follower's Atk and Life by exactly
|
||||
// those amounts over its CardCSVData base — a self-buff, so no target selection is involved.
|
||||
[TestFixture]
|
||||
public class BuffFollowerOracleTests
|
||||
{
|
||||
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 Self_buff_fanfare_raises_own_atk_and_life()
|
||||
{
|
||||
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/M3 oracles): opponent refs + active turn flag. The
|
||||
// self-buff's 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 state that silently blocks
|
||||
// the play (M3 learning); this card deals no damage but the play-legality gate still checks it.
|
||||
HeadlessEngineEnv.InitLeaderLife(mgr);
|
||||
|
||||
// The card's fanfare is gated on `play_count>2` (cards.json skill_condition for 103111050).
|
||||
// The engine reads this from BattlePlayerBase.GetCurrentTurnPlayCount(); seed it past the
|
||||
// threshold via the public AddCurrentTrunPlayCount so the powerup actually fires. (Without
|
||||
// this the card resolves to the board but takes no buff — the delta-vs-base oracle is what
|
||||
// distinguishes "buff applied" from "fanfare silently gated out".)
|
||||
player.AddCurrentTrunPlayCount(5);
|
||||
|
||||
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.BuffFollowerId);
|
||||
|
||||
// Place the 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;
|
||||
|
||||
// Pre-state snapshot.
|
||||
int ppBefore = player.Pp;
|
||||
int handBefore = player.HandCardList.Count;
|
||||
int inplayBefore = player.ClassAndInPlayCardList.Count;
|
||||
int enemyHandBefore = enemy.HandCardList.Count;
|
||||
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
|
||||
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
|
||||
|
||||
// Resolve the play through the real engine.
|
||||
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
|
||||
var ap = new ActionProcessor(pair);
|
||||
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
|
||||
"ActionProcessor.PlayCard threw on a self-buff fanfare follower");
|
||||
|
||||
// Oracle: the own-stat delta is the new M4 dimension; the rest are the §5 follower invariants.
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// Primary M4 assertion: the fanfare powerup raised the follower's own stats by exactly
|
||||
// the buff amounts over its CardCSVData base (1/1 -> 2/2).
|
||||
Assert.That(card.Atk, Is.EqualTo(cardParam.Atk + HeadlessEngineEnv.BuffAddOffense),
|
||||
"follower atk != base + fanfare add_offense");
|
||||
Assert.That(card.Life, Is.EqualTo(cardParam.Life + HeadlessEngineEnv.BuffAddLife),
|
||||
"follower life != base + fanfare add_life");
|
||||
// Cost paid.
|
||||
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
|
||||
// Follower moved hand -> board.
|
||||
Assert.That(player.HandCardList, Does.Not.Contain(card), "card still in hand");
|
||||
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
|
||||
Assert.That(player.ClassAndInPlayCardList, Contains.Item(card), "card not in play");
|
||||
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(inplayBefore + 1), "in-play count not +1");
|
||||
// Opponent unchanged (the buff targets self, not the opponent).
|
||||
Assert.That(enemy.HandCardList.Count, Is.EqualTo(enemyHandBefore), "opponent hand changed");
|
||||
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board changed");
|
||||
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), "opponent leader life changed");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,25 +26,6 @@ namespace SVSim.BattleEngine.Tests
|
||||
f.SetValue(obj, value);
|
||||
}
|
||||
|
||||
private static BattleCardBase CreateHeadlessHandCard(int cardId, int index, bool isPlayer, BattleManagerBase mgr)
|
||||
{
|
||||
// Engine's own null-view creation path (CreateBase -> new *BattleCard(buildInfo).Setup(true));
|
||||
// private, so reflect it — same seam the M2 oracle uses.
|
||||
var io = mgr.CreatePlayerInnerOptionsBuilder();
|
||||
var m = typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var card = (BattleCardBase)m.Invoke(null, new object[] { cardId, index, isPlayer, mgr, io });
|
||||
// The engine's CreateCard wires per-card play events via SetupCardEvent; the raw
|
||||
// CreateCardWithoutResources path skips it. For a SPELL this matters: SetupCardEvent
|
||||
// attaches OnPlay -> RemoveSpellCardFromHand and OnFinishWhenPlaySkill ->
|
||||
// AddSpellCardToCemetery (BattlePlayerBase.cs:1462-1466). Without it the spell resolves
|
||||
// its damage but never leaves the hand. (Harmless for the M2 follower, whose hand->field
|
||||
// move is intrinsic to SetUpInplay, not event-driven.)
|
||||
BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy;
|
||||
owner.SetupCardEvent(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Fixed_damage_spell_reduces_opponent_leader_life()
|
||||
{
|
||||
@@ -70,7 +51,7 @@ namespace SVSim.BattleEngine.Tests
|
||||
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.SpellId);
|
||||
|
||||
// Place the spell in the active player's hand with PP to spare; empty board otherwise.
|
||||
var card = CreateHeadlessHandCard(HeadlessEngineEnv.SpellId, 1, isPlayer: true, mgr);
|
||||
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.SpellId, 1, isPlayer: true, mgr);
|
||||
player.HandCardList.Add(card);
|
||||
player.Pp = 10;
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Reflection;
|
||||
using Wizard;
|
||||
using Wizard.Battle;
|
||||
using Wizard.Battle.Phase;
|
||||
using Wizard.Battle.Recovery;
|
||||
using Wizard.Battle.Replay;
|
||||
@@ -19,6 +22,16 @@ namespace SVSim.BattleEngine.Tests
|
||||
// (the enemy leader) — auto-targeted (no select_count), no RNG. Deterministic burn to the face.
|
||||
public const int SpellId = 900124030;
|
||||
|
||||
// M4 next-hardest deterministic card: a when_play SELF-BUFF follower. 103111050 is an ELF
|
||||
// (clan 1) cost-1 1/1 whose sole non-evo skill is `when_play` `powerup` `add_offense=1&add_life=1`
|
||||
// with skill_target `character=me&target=self` — it buffs ITSELF, so no target selection (the
|
||||
// fanfare auto-resolves). Fixed +1/+1 => a deterministic stat-delta oracle. The skill is gated on
|
||||
// `play_count>2`; the headless harness seeds that via the public AddCurrentTrunPlayCount (see the
|
||||
// oracle test). Base 1/1 -> 2/2 after the fanfare.
|
||||
public const int BuffFollowerId = 103111050;
|
||||
public const int BuffAddOffense = 1;
|
||||
public const int BuffAddLife = 1;
|
||||
|
||||
private static bool _done;
|
||||
|
||||
public static void EnsureInitialized()
|
||||
@@ -34,9 +47,9 @@ namespace SVSim.BattleEngine.Tests
|
||||
.SetValue(null, new Wizard.Crossover());
|
||||
BattleManagerBase.IsForecast = true;
|
||||
// CardMaster must be non-null before construction (the leader/class card looks up id 0).
|
||||
// Load the M2 vanilla follower + the M3 fixed-damage spell so each oracle can create +
|
||||
// look up its real stats.
|
||||
HeadlessCardMaster.Load(FollowerId, SpellId);
|
||||
// Load the M2 vanilla follower + the M3 fixed-damage spell + the M4 self-buff follower so
|
||||
// each oracle can create + look up its real stats.
|
||||
HeadlessCardMaster.Load(FollowerId, SpellId, BuffFollowerId);
|
||||
// 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).
|
||||
@@ -64,6 +77,30 @@ namespace SVSim.BattleEngine.Tests
|
||||
((ClassBattleCardBase)mgr.BattleEnemy.Class).InitBaseMaxLife(life);
|
||||
}
|
||||
|
||||
// The shared headless card-creation primitive. CardCreatorBase.CreateCardWithoutResources is
|
||||
// the engine's own null-view creation path (CreateBase -> new *BattleCard(buildInfo).Setup(
|
||||
// createNullView:true)); it's private, so reflect it rather than reimplement the 14-arg
|
||||
// BuildInfo wiring. The public CardCreatorBase.CreateCard goes through prefab cloning.
|
||||
//
|
||||
// The engine's CreateCard also calls owner.SetupCardEvent(card); the raw
|
||||
// CreateCardWithoutResources seam skips it, so we fold it in here. SetupCardEvent wires the
|
||||
// per-card play events (BattlePlayerBase.cs:1452): for a SPELL/amulet it attaches
|
||||
// OnPlay -> RemoveSpellCardFromHand and OnFinishWhenPlaySkill -> AddSpellCardToCemetery, which
|
||||
// are how a non-follower leaves the hand at all (a follower's hand->field move is intrinsic to
|
||||
// SetUpInplay, not event-driven). For a follower SetupCardEvent only attaches an OnEvolve hook
|
||||
// that never fires on a vanilla play, so folding it in is a no-op there — making this a single
|
||||
// primitive both follower and non-follower oracles can share.
|
||||
public static BattleCardBase CreateHeadlessHandCard(int cardId, int index, bool isPlayer, BattleManagerBase mgr)
|
||||
{
|
||||
var io = mgr.CreatePlayerInnerOptionsBuilder();
|
||||
var m = typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var card = (BattleCardBase)m.Invoke(null, new object[] { cardId, index, isPlayer, mgr, io });
|
||||
BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy;
|
||||
owner.SetupCardEvent(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static void SetField(object obj, string name, object value)
|
||||
{
|
||||
var f = obj.GetType().GetField(name,
|
||||
|
||||
@@ -23,17 +23,6 @@ namespace SVSim.BattleEngine.Tests
|
||||
f.SetValue(obj, value);
|
||||
}
|
||||
|
||||
private static BattleCardBase CreateHeadlessHandCard(int cardId, int index, bool isPlayer, BattleManagerBase mgr)
|
||||
{
|
||||
// CardCreatorBase.CreateCardWithoutResources is the engine's own null-view creation path
|
||||
// (CreateBase -> new UnitBattleCard(buildInfo).Setup(createNullView:true)); it's private,
|
||||
// so reflect it rather than reimplement the 14-arg BuildInfo wiring.
|
||||
var io = mgr.CreatePlayerInnerOptionsBuilder();
|
||||
var m = typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
return (BattleCardBase)m.Invoke(null, new object[] { cardId, index, isPlayer, mgr, io });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Vanilla_follower_resolves_to_correct_state()
|
||||
{
|
||||
@@ -57,7 +46,7 @@ namespace SVSim.BattleEngine.Tests
|
||||
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.FollowerId);
|
||||
|
||||
// Place the follower in the active player's hand with PP to spare; empty board otherwise.
|
||||
var card = CreateHeadlessHandCard(HeadlessEngineEnv.FollowerId, 1, isPlayer: true, mgr);
|
||||
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.FollowerId, 1, isPlayer: true, mgr);
|
||||
player.HandCardList.Add(card);
|
||||
player.Pp = 10;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user