feat(battle-engine-port): M9 COMPLETE — when_play draw resolves headless (hand/deck-delta oracle)

Proves the deck->hand transfer dimension (design §5 draw oracle) — the last
deterministic, non-RNG card-effect class no prior milestone touched (M3/M4/M6/M8
moved stats, M2/M5/M7 the board, M3 the leader).

Card 800114010 (clan-1 ELF cost-1 when_play draw 1 from own deck, ungated, no
evo/preprocess). The resume-guide's skill_target=none/no-RNG shape does not exist
in cards.json — EVERY draw selects from the deck via a random_count filter
(skill_option is always literally 'none'). RNG neutralized structurally: seed the
deck with EXACTLY ONE known card so random_count=1 is deterministic regardless of
seed. New primitive HeadlessEngineEnv.SeedDeck (create via the null-view seam +
engine AddToDeck). Oracle DrawSpellOracleTests asserts: seeded card moves deck->hand
(by id + by reference), deck -1, drawn card IsInHand, spell pays cost + leaves hand
+ resolves to cemetery, board/opponent untouched. Load-bearing confirmed the M7 way
(seed a different id -> the by-id assertion fails).

Shim gap fixed (the predicted M9 cost): Skill_draw's BattleLog tail
(UpdateFusionedCardSkillDrewCard, unguarded; + the IsBattleLog AddLogSkillDrawCard
calls) dereferences BattleLogManager.GetInstance(), an M1 'default!' null singleton
-> NRE after the draw already committed. One-line HEADLESS-FIX (M9) in
BattleLogManager.g.cs returns the existing _instance singleton (all its methods are
no-ops), per the M2/M7 Null*-singleton playbook. No Engine/ edit (drift clean).

9/9 green; check_drift.py clean; engine still 0 Error(s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 08:47:04 -04:00
parent 4f76fb21f0
commit eee8450144
3 changed files with 155 additions and 2 deletions

View File

@@ -98,6 +98,24 @@ namespace SVSim.BattleEngine.Tests
public const int LethalTargetFollowerId = FollowerId; // neutral 1/2 (life 2 <= 5 -> dies)
public const int SurvivorTargetFollowerId = UnselectTargetFollowerId; // neutral 6/7 (life 7 > 5 -> survives at 2)
// M9 next milestone: when_play DRAW — proves the HAND/DECK DELTA dimension (design §5's draw
// oracle): the last deterministic, non-RNG card-effect class no prior milestone touched (M3/M4/
// M6/M8 moved stats, M2/M5/M7 the board, M3 the leader — none read the deck->hand transfer).
// 800114010 is an ELF (clan 1) cost-1 SPELL whose sole skill is `when_play` `draw` of ONE card
// from the caster's own deck (skill_target=character=me&target=deck&card_type=all&random_count=1),
// ungated (skill_condition=character=me), no evo skill, no preprocess, no dynamic `{}` value.
//
// ADAPTATION FROM THE RESUME-GUIDE SHAPE: the guide asked for a `skill_target=none` draw with
// "no RNG", but no such card exists in cards.json — EVERY draw selects from the deck via a
// `random_count=N` target filter (skill_option is always literally `none`; the count lives in
// skill_target). The RNG is neutralized structurally instead: seed the deck with EXACTLY ONE
// known card, so `random_count=1` over a single-card pool is deterministic regardless of the
// RandomSeed. This keeps the oracle decisive (drawn id is forced) while exercising the real
// draw path. Like the summon token, a drawn card is engine-CREATED off the deck the M5 prefab
// way; unlike summon, the card already exists (we seed it) and the skill only MOVES it deck->hand.
public const int DrawSpellId = 800114010;
public const int DeckSeedCardId = FollowerId; // the single known deck card (neutral 1/2 vanilla)
private static bool _done;
public static void EnsureInitialized()
@@ -118,7 +136,7 @@ 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);
TargetSpellId, SelectTargetFollowerId, UnselectTargetFollowerId, DestroySpellId, DrawSpellId);
// 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).
@@ -214,6 +232,23 @@ namespace SVSim.BattleEngine.Tests
return card;
}
// Push a known card onto a player's DECK headless (the M9 draw oracle's setup primitive). The
// bare `new SingleBattleMgr(...)` construction leaves DeckCardList non-null-but-empty (ctor at
// BattlePlayerBase.cs:1050), and a card's deck membership IS its `IsInDeck` (BattleCardBase.cs:970
// `=> SelfBattlePlayer.DeckCardList.Contains(this)`) — so no separate "in deck" flag is needed.
// Create the card through the same null-view seam hand/board cards use, then drive the engine's
// own AddToDeck (BattlePlayerBase.cs:3038): for a vanilla follower it is just DeckCardList.Add
// (HasDeckSelfSkill is false; the XorShiftRandom/IsMulliganEnd reshuffle bookkeeping short-
// circuits on the null/inactive headless RNG). The drawn card is then the engine's own deck
// object, so the oracle can assert deck->hand identity by reference, not just by id.
public static BattleCardBase SeedDeck(BattleManagerBase mgr, int cardId, int index, bool isPlayer)
{
var card = CreateHeadlessHandCard(cardId, index, isPlayer, mgr);
BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy;
owner.AddToDeck(card);
return card;
}
private static void SetField(object obj, string name, object value)
{
var f = obj.GetType().GetField(name,