48 Commits

Author SHA1 Message Date
gamer147
fbac66fd0b chore(engine-ambient): harden shim + LocalLog statics for fixture parallelism
Follow-up to the multi-instancing migration. Wraps the process-shared engine
statics that aren't ambient-fronted but race between concurrent battles:

- UnityEngine.Resources._loaded: Dictionary -> ConcurrentDictionary.GetOrAdd
  (the shared prefab cache keyed by path; concurrent first-misses produced
  duplicate GameObjects + Dictionary corruption)
- UnityEngine.GameObject._components: Dictionary -> ConcurrentDictionary with
  Interlocked.CompareExchange init (Resources.Load returns SHARED prefab
  GameObjects, so two engines' Setup() can race on the same _components map
  — surfaced as "Operations that change non-concurrent collections" crashes
  during BattleManagerBase ctor's GetComponent<T>() chain)
- Wizard.LocalLog: single static lock around all mutating entry points
  (StringBuilder _lastTraceLogStringBuilder + ~12 mutable string/bool/int
  scratch fields; serializing the trace-log surface is cheap since logging
  is not the hot path)

Flips SVSim.BattleEngine.Tests assembly Parallelizable scope from Self to
Fixtures and restructures MultiInstanceEngineTests.StressN_BaselineMatches so
Setup runs INSIDE Task.Run (was previously serialized as a workaround for the
LocalLog races). The fixture is also lifted to ParallelScope.All so the
two-engines and stress tests can run alongside each other.

Suite fully green under fixture parallelism (59/0/2 across 3 consecutive runs);
SVSim.UnitTests still 1054/0/0 — true multi-instance correctness is now proved
end-to-end in tests rather than gated behind a serial workaround.

Manifest sha refresh + new patch artifact for the LocalLog edit (decomp-origin);
the two shim files are authored, so no metadata update is needed for them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 08:02:49 -04:00
gamer147
ab4545b274 test(engine-ambient): tighten MultiInstanceEngineTests post-setup assertions
Replace trivially-true Pp>=0 with concrete post-Setup pins (LeaderLife=20,
Pp=0, HandCount=3). Drop the unused seed parameter from SampleDeck - every
call already returned the same vanilla deck, and the StressN test name 'Random
Decks' overpromised. The cross-contamination property the test pins (parallel
LeaderLife[] equals sequential LeaderLife[]) holds with identical decks +
distinct masterSeeds, which is what's actually being verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 23:28:21 -04:00
gamer147
c789d836f1 feat(engine-ambient): delete static fallbacks; add MultiInstanceEngineTests
Step 8 (final) of multi-instancing migration. All per-battle statics now
require a BattleAmbient scope — unwrapped writes throw InvalidOperationException
(fail-fast forcing function). MultiInstanceEngineTests proves correctness:
two parallel battles resolve independently, N=4/8/16 stress matches sequential
baseline, GameMgr.GetIns throws without scope.

Migration complete. EngineSessionGate gone. Suite fully green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 23:19:37 -04:00
gamer147
8af1be6555 test(engine-ambient): TestBattleScope + HeadlessFixture split for multi-instance
Step 6 of multi-instancing migration. HeadlessEngineEnv.EnsureInitialized
is split into EnsureProcessGlobals (idempotent, process-once) +
SeedCharaIdsOnCurrentAmbient (per-test). New TestBattleScope IDisposable
sets up a fresh BattleAmbientContext per test. NonParallelizable removed
from converted classes; assembly-level Parallelizable(Fixtures) enabled.

SVSim.BattleEngine.Tests fully green under parallel test execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 22:24:21 -04:00
gamer147
1ba75c565a refactor(engine-ambient): GameMgr.GetIns throws Require; wrap SessionBattleEngine entry points
Step 5 of multi-instancing migration. GameMgr.GetIns() now resolves through
BattleAmbient.Require() (throws when no scope active — fail-fast since engine
callers unconditionally dereference). SessionBattleEngine now owns a single
BattleAmbientContext, pushed via BattleAmbient.Enter at Setup/Receive/all
~30 read accessors and Debug* seams.

EngineGlobalInit.WirePerSessionGameMgr extracted out of the _done-gated block:
GameMgr is now per-session (ctx.GameMgr is a fresh `new()` per SessionBattleEngine),
so the DataMgr chara ids + NetworkUserInfoData seeding must run every Setup, not
process-once. The wiring itself is already idempotent. Without this, second-or-
later sessions in a process NRE in NetworkBattleManagerBase.CreateBackgroundId.

Expected state: SVSim.BattleEngine.Tests have known-failing tests that don't
go through SessionBattleEngine (Task 6 wraps HeadlessFixture). SVSim.UnitTests
mostly recover; residual failures (deal-frame Accepted:false in conductor
integration tests) are captured in
data_dumps/task5-test-output/failing-tests-after-task5-node-postwrap.txt for
Task 7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:56:34 -04:00
gamer147
18da7fd19e test(engine-ambient): cover BattleRecoveryInfo setter ambient write-through
Setter is the asymmetric one (write-through inside scope, unlike ViewerId's
no-op-in-scope) — adding parity with the SetRealTimeNetworkBattle ambient
setter test to catch future regressions if the routing branch is touched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:42:55 -04:00
gamer147
fe146fde50 refactor(engine-ambient): ViewerId/RealTimeNetworkAgent/BattleRecoveryInfo read ambient first
Step 4 of multi-instancing migration. Three additional per-battle statics
front-fronted by BattleAmbient.Current, each with a static fallback for
unwrapped callers. ViewerId's SavedataManager-persisting setter is preserved
on the fallback path; inside a scope, the setter is a no-op (the per-battle
perspective is fixed at scope entry).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:37:58 -04:00
gamer147
4e756a6c46 refactor(engine-ambient): BattleManagerBase.GetIns reads ambient first, static fallback
Step 3 of multi-instancing migration. The dominant per-battle singleton now
resolves through BattleAmbient.Current.Mgr when a scope is active. The legacy
'main' field is renamed _mainFallback and retained for unwrapped callers
(tests, anything not yet scope-wrapped). GetIns() still returns null when
neither is set, preserving the '?.Foo ?? default' patterns in engine code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:23:27 -04:00
gamer147
3b5f2e18b3 refactor(engine-ambient): IsForecast/IsRandomDraw read ambient first, static fallback
Step 2 of multi-instancing migration. Both flags now resolve through
BattleAmbient.Current when a scope is active, otherwise hit a static fallback
that preserves today's behavior unchanged for unwrapped callers.

Suite green: SVSim.BattleEngine.Tests pass; SVSim.UnitTests baseline holds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:49 -04:00
gamer147
4829e8c263 feat(engine-ambient): add BattleAmbientContext + AsyncLocal scope
Step 1 of the engine multi-instancing migration. Standalone infrastructure;
no engine static reads/writes through it yet. Scope is reentrant (restores
prior on dispose), AsyncLocal flows across awaits, and isolated between
concurrent Task.Run flows.

See docs/superpowers/specs/2026-06-07-engine-multi-instancing-design.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:04:21 -04:00
gamer147
addeb021d2 fix(battlenode): shadow engine tracks live PvP wire-truth (full battle, multiple bid regressions)
Six distinct fixes accumulated over live-test iterations against four bids
(654473755566, 806245601092, 283192092460, 131549100204, 799755786270) — together
they take the shadow engine from "throws on the first non-mulligan play" to
"survives a full PvP battle, only weird-edge-case Unity touches still left to whack".

1. Engine StableRandom seed aligned with clients' Matched.seed
   (BattleSession.EnsureEngineSetup, NodeNativeBattleHarness.Create). Clients seed
   _stableRandom with BattleSeeds.Stable(masterSeed) (the value the node ships in
   Matched.seed); we were passing the RAW masterSeed to engine.Setup, so every
   StableRandom call diverged from call #1 onward — every turn-1+ draw picked a
   different deck position than the clients. Verified Stable(1184631275)=1543475792
   matches the wire on bid 654473755566.

2. SeedDeck advances cardTotalNum to deck.Count+1 + pins BattleStartDeckCardList.
   Mirrors SBattleLoad.InitPlayer's tail (SBattleLoad.cs:1292). Without it,
   skill-generated tokens auto-assigned Index 0,1,... and COLLIDED with deck-loaded
   indices 1..40 — silent until something addressed the deck card with the
   colliding Index (Hoverboarder at deck idx 1 + a token at engine Index 1 made
   GetBattleCardIdx's SingleOrDefault throw on bid 806245601092).

3. BattleCardView.GameObject lazily non-null in the shim (ViewUiTouchStubs.cs).
   The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes
   null cardGameObject; Skill_metamorphose.cs:147 in the in-play branch then NRE'd
   on `metamorphosedCard.BattleCardView.GameObject.transform.rotation = identity`,
   a purely cosmetic touch with no game-state implication. Bid 283192092460:
   Petrification on a board follower.

4. TranslateChoiceKeyAction unwraps wrapped selectCard on shadow ingest
   (SessionBattleEngine.cs, sibling to TranslateTargetOwners). Live sender-send
   wires Choice plays as selectCard:{cardId:[...], open:0}; engine's
   ConvertToListInt does `value as List<object>` — a Dict casts to null and
   foreach NREs. The receiver's swallow-all catch (NetworkBattleReceiver.cs:1255)
   logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and
   returns false, but Receive calls ReceivedMessage with checkBreakData:false so
   the false isn't propagated. The play continues with choiceIdList=[], the chosen
   branch never resolves, the played card stays in hand; a later targeted play
   (A's bounce on B's "board" idx 20) then can't find the target → NRE on null in
   ActionProcessor.PlayCard:407. Bid 131549100204: B's Resonance + A's bounce.
   Opponent-relay path is unaffected — node strips selectCard from broadcasts.

5. HeadlessHandViewStub overrides HandUnfocus/HandFocus/FocusRearrangeHandHand
   to return NullVfx. CreateHandControl returns null in headless; the base
   methods unconditionally deref `_handControl.SetHandState(...)`. A follower
   with a when_spell_play Heal trigger fired on its leader for amount 0 — even
   a 0-heal drives ApplyHealing → CreatePullHandInVfx → HandUnfocus → NRE.
   Bid 799755786270: two consecutive spell plays both crashed this stack.
   Added InternalsVisibleTo("SVSim.BattleEngine.Tests") so the shim-level
   regression tests can pin the no-op contracts directly.

Plus the previous-session fixes carried in this same uncommitted state
(see docs/superpowers/plans/2026-06-07-shadow-engine-desync-handoff.md):
  - doesPlayerGoFirst:true + mgr.IsFirst:true (turn-1 draw count correct
    per seat)
  - RecoveryOperationCollection.PlayHandCardOperation routes all type:30
    through PlaySkillSelectHandCardOperation (skips the two-phase user-select
    guard that aborts targeted spells in recovery)
  - ShadowFeed + ToRawBody: server-generated typed bodies (DealBody, etc.)
    converted to RawBody before engine.Receive (`env.Body as RawBody`
    returned null for typed bodies)
  - Ready idxChangeSeed seeds A's XorShift via the receiver; B's seed is
    injected via SeedOppoIdxChange (BattleSeeds.IdxChange + viewerId)
  - ReadySpin defaulted to 0 (was 243) — non-zero double-cranks the shadow
    which ingests BOTH sides' Ready frames on one stream

Test counts: SVSim.UnitTests 1054/1054, SVSim.BattleEngine.Tests 34/34.

Open: known-residual Unity touches are individual whack-a-mole now (per-card
skill edge cases), not the structural divergences fixed here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 19:05:07 -04:00
gamer147
2a8c44a6d7 build(battleengine): pin LangVersion 12.0 (was 'latest') so C# 14's 'field' keyword doesn't break the decompiled engine under newer SDKs 2026-06-07 08:03:01 -04:00
gamer147
35e9847911 feat(battlenode): receive conductor resolves self Deal+Play headless via view-untangle (M-HC-0)
The engine's receive CONDUCTOR fuses each authoritative mutation behind a view
call: the play mutation is an InstantVfx registered to VfxMgr, and the deal hand
is seated by MulliganPhaseBase.StartDeal wired to OperateReceive.OnReceiveDeal.
Headless, the shared VfxMgr no-op'd registration (correct for the direct
ActionProcessor path the M2-M12 oracles use) and OnReceiveDeal was never wired,
so the receive path resolved nothing.

Untangle (Candidate B, zero Engine logic edits):
- InstantVfx.Run() opt-in executor (authored shim).
- HeadlessConductorVfxMgr : VfxMgr runs registered InstantVfx; wired only via the
  node's SessionContentsCreator.CreateVfxMgr (verified the receive mgr's VfxMgr
  comes from there — BattleManagerBase.cs:768). M2-M12 use HeadlessContentsCreator,
  so they're isolated by construction.
- WireMulliganPhase: construct NetworkMulliganPhase + MulliganEventSetting() to
  install OnReceiveDeal -> StartDeal (the node never pumps the phase machine).

View no-op surface (the 7 from the probe, minus 1 not hit; +1 emergent):
- Deal wiring (NetworkMulliganPhase) [node seed]
- MulliganInfoControl._partsPlayer/_partsOpponent._exchangeMark/_keepZone/_abandonZone [node seed: prefab + SeedMulliganInfoControl]
- Data.BattleRecoveryInfo (IsMulliganEnd=false) [EngineGlobalInit seed]
- IBattlePlayerView.PlayQueueView -> HeadlessPlayQueueViewStub [_IfaceImpl.g.cs, both getters]
- DetailMgr.DetailPanelControl/SubDetailPanelControl [node seed]
- BattleCardIconAnimations.collection (emergent: UpdateInPlayBattleCardIconLabel) -> HeadlessIconAnimations empty SkillCollectionBase [_IfaceImpl.g.cs]
- BattleMenuBtn (probe item 7): NOT hit on the vanilla path; not seeded.

Oracle (HeadlessConductorTests): node Deal seats 3-card hand; a vanilla
hand-card Play leaves hand (-1), adds board (+1), drops PP by cost.

Regression: 24/24 BattleEngine.Tests oracles (M2-M12) green; 241/241
SVSim.UnitTests BattleNode green. The 2 SessionEngine capture-replay shadow
tests are marked Ignore (superseded): they passed VACUOUSLY when the receive
path resolved nothing; with resolution live they hit the documented
capture-replay draw-misalignment artifact. Node-native battles are the oracle.
Drift: no drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:08:53 -04:00
gamer147
fcc30ffe5e refactor(battlenode): drop obsolete pre-ingest spellboost peek (Phase 2 revised, O-HC-5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 19:28:21 -04:00
gamer147
fcd64c8c11 feat(battlenode): engine read surface for played-card spellboost (Phase 2 N2 Task 3 — oracle BLOCKED)
Adds SessionBattleEngine.PlayedCardSpellboost + PeekPlayedCardSpellboost (pre-resolve
read of the acting seat's hand card by Index==playIdx) and a CaptureReplay.InterleavedSends
helper. The non-circular capture oracle (engine-derived spellboost vs prod's independent
emission to cl2: idx2->1, idx14->2) is added but [Ignore]'d: the headless receive path does
not apply the wire's authoritative orderList (Deal/Swap don't seat the mulligan hand, draws
follow the seeded deck top instead of the wire move ops, plays never remove the card, alter
spellboost never accumulates), so the engine cannot yet DERIVE the count. Closing this needs
an Engine/*.cs + VfxMgr-execution logic change (escalation per the N2 playbook), not a
mechanical no-op fill. Read surface, node + engine builds, drift, and the rest of the
SessionEngine suite are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:55:47 -04:00
gamer147
6e8af4e68b fix(battlenode): EngineGlobalInit guarantees full-master postcondition (Phase 2 N2 review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:29:04 -04:00
gamer147
5e0723c182 feat(battlenode): host-owned engine global init (Phase 2 N2 carried-risk A)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:19:21 -04:00
gamer147
fa86739ac2 test(battlenode): N1 shadow replay tracks captured battle state (Phase 2 N1)
Full single-client capture replay (cl1 send=player seat, receive=opponent seat,
ts-ordered) ingests end-to-end: 33 frames, 0 rejects, 0 invariant violations at
turn boundaries (leader life/PP/board/hand).

Headless gaps filled per playbook (no Engine/ drift):
- IsRecovery=true after construction: the engine's own headless replay mode gates
  the live view/UI layer off (BattleUIContainer, turn-control UI, VFX waits) while
  keeping the live NetworkBattleReceiver (ND4) and authoritative state.
- Seed ToolboxGame.RealTimeNetworkAgent, BattleUIContainer, _backGround, and
  per-player NullPlayerEmotion no-ops the receive/turn cycle dereferences.
- _IfaceImpl.g.cs (shim, not Engine/): BattleCardView.BattleCardIconAnimations
  returns a lazy non-null no-op so the opponent card-reveal icon-init (deferred
  VFX) doesn't NRE.
- HeadlessCardMaster.Load made cumulative: it replaced the global CardMaster each
  call, so a Load(deck) evicted the oracle card set and broke tests run after.

Adds board-state accessors (LeaderLife/Pp/HandCount/BoardCount) and CaptureReplay
ts ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:28:08 -04:00
gamer147
6740313446 feat(battlenode): Receive ingests a captured PlayActions headless (Phase 2 N0)
Receive feeds the decoded frame into the mgr's own NetworkBattleReceiver
(isHaveSequence:true, checkBreakData:false — mirroring the engine's
RecoveryDataHandler frame replay), reboxing object?->object for nested data.
No engine gaps surfaced; the only fix was a test-harness one (load all deck ids
in a single HeadlessCardMaster.Load — per-id calls each replace the master).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:05:36 -04:00
gamer147
eaa7b4d85c test(battlenode): capture-replay helper + battle_test fixtures (Phase 2 N1)
CaptureReplay normalizes the capture's send/receive envelope asymmetry (send
frames carry uri at top level + bare payload body; receive frames carry a full
envelope body) and extracts selfDeck + master seed from Matched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:59:48 -04:00
gamer147
c9841c012b feat(battlenode): Setup builds two-seat network battle headless (Phase 2 N0)
Mirrors HeadlessFixture.NewNetworkEmitBattle wiring (opponent seating, leader
life, card templates, deck seeding) minus the emit-only RealTimeNetworkAgent
scaffolding (shadow only receives). Probe passed first run — M13 already filled
the network-mgr construction gaps. No Engine/ edits; drift clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:51:51 -04:00
gamer147
f6cbde723b feat(battlenode): SessionBattleEngine skeleton + types (Phase 2 N0)
SessionContentsCreator mirrors the test HeadlessContentsCreator fully (all
IBattleMgrContentsCreator members) so it compiles; Setup/Receive throw pending
the Task 3/4 probes. New files use the 'engine' extern alias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:49:18 -04:00
gamer147
e6a561b30f test(battle-engine M13): shared NetworkEmitFixtureBase teardown — close IsForecast/agent global leak
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:03:07 -04:00
gamer147
bfd99c4829 docs(battle-engine M13): note _notEmit precondition on TryReadStockedEmitData (review polish)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:42:45 -04:00
gamer147
feb47f6437 test(battle-engine M13): best-effort emit-payload presence (Inconclusive => deferred to structural validation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:37:24 -04:00
gamer147
73286ba78b chore(battle-engine M13): align OnEmit line-cite + HEADLESS marker spelling (review polish)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:53 -04:00
gamer147
ac0886389a feat(battle-engine M13): M3 spell emits PlayActions headless via OperateMgr -> NetworkBattleSender (O1 read = GO)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:23:51 -04:00
gamer147
25e9ae9573 test(battle-engine M13): NewNetworkEmitBattle harness + OnEmit capture seam
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:00:04 -04:00
gamer147
6b2c825eb8 chore(battle-engine M13): drop unused using + complete shim comment (review polish)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:56:23 -04:00
gamer147
2f6bc5b6c0 test(battle-engine M13): HeadlessNetworkBattleMgr constructs headless (construction probe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:48:52 -04:00
gamer147
0fe45517da test(rng-seam): reset IsRandomDraw in RandomDrawOracleTests teardown (avoid cross-fixture leak) 2026-06-06 10:57:39 -04:00
gamer147
ffc0fcaa43 test(rng-seam): M12 oracle — scripted RNG draws a known deck card (genuine multi-outcome roll) 2026-06-06 10:46:35 -04:00
gamer147
2fd0aac5b6 test(rng-seam): M12 constants + NewAuthoritativeBattle harness factory 2026-06-06 10:40:59 -04:00
gamer147
c47f8d9fa7 feat(rng-seam): HeadlessBattleMgr override + decoupling/parity tests (F2 resolved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:33:59 -04:00
gamer147
1a108fa393 feat(rng-seam): ScriptedRandomSource (throw-on-overrun deterministic source) 2026-06-06 10:21:44 -04:00
gamer147
2fd42c10cf feat(rng-seam): SeededRandomSource mirrors the engine's two System.Random streams 2026-06-06 10:18:19 -04:00
gamer147
c77d789558 feat(rng-seam): IRandomSource interface + RandomSourceBridge arithmetic
Adds the RNG seam skeleton (Task 1 of M12): IRandomSource (NextUnit/NextSelf)
and RandomSourceBridge.Range mirroring BattleManagerBase.StableRandom exactly
(`(int)Math.Floor(val * unit)`). RngSeamTests pins the floor arithmetic (1 test, passing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:14:50 -04:00
gamer147
7370a35e9c test(battle-engine-port): M11 — gated conditional resolves headless (the GATE is the oracle)
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 <noreply@anthropic.com>
2026-06-06 09:10:45 -04:00
gamer147
c3590e9c9b test(battle-engine-port): M10 — first dynamic {}-value card resolves headless
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 <noreply@anthropic.com>
2026-06-06 09:02:59 -04:00
gamer147
eee8450144 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>
2026-06-06 08:47:04 -04:00
gamer147
4f76fb21f0 feat(battle-engine-port): M8 COMPLETE — lethal damage proves follower death via combat math
A when_play damage=5 spell (the M6 card 800134020) played at a select_count=1
enemy follower with life <= 5 kills it as a consequence of damage -> life <= 0 ->
the dead-check + the same RemoveInplayCard/cemetery path M7 lit up (the dominant
real-card removal mechanic), reached through combat math rather than `destroy`.

Oracle LethalDamageSpellOracleTests: selected follower (1/2) removed (board -1 +
cemetery +1, the M7 dimension); un-selected control (6/7, life > 5) untouched and
still on board (M6 routing; select_count=1 hits only the selected target). 8/8
green; engine 0 errors; check_drift clean; ZERO new Engine/shim/manifest work —
the death path inherited M7's death-voice fix; the predicted damage-VFX shadow
never materialized.

Load-bearing (M4/M6 discipline): swapping the selection to the 6/7 -> it survives
at 2 and nobody dies, proving removal is gated on the SELECTED follower's life
reaching <= 0, not on selection (M7's destroy) or a blanket wipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:35:02 -04:00
gamer147
9fc97abee7 feat(battle-engine-port): M7 COMPLETE — targeted destroy resolves headless (follower death / board-removal)
First proof that follower DEATH / board-removal commits in the authoritative
part of PlayCard headless (not the cosmetic post-Process tail). Card 800144120
(cost-0 when_play destroy of a select_count=1 enemy follower) resolves via the
M6 selectedCards path: selected enemy follower removed (board -1 + cemetery +1),
un-selected untouched (routing confirmed load-bearing by swapping the selection).

Shim gap fixed (the predicted M7 cost): SkillProcessor.SelectCardToHaveDestroyVoicePlay's
cosmetic death-voice tail NRE'd on three M1 default!/Null* shadows
(IBattleCardView.VoiceInfo, CardVoiceInfoCache.GetCardVoiceInfoForBattle,
ReadOnlyVoiceInfo.GetDestroyVoice — the last unusable as the interface since
m1_stub_gen dropped its : IReadOnlyVoiceInfo base). Fix = one hand shim
HeadlessVoiceInfo : IReadOnlyVoiceInfo returning the engine's own
VoiceAndWaitTime._nullVoice sentinel, wired into the two generated seams with
// HEADLESS-FIX markers. No Engine/ edit (drift clean).

dotnet test SVSim.BattleEngine.Tests -> 7/7 green; check_drift.py clean; engine 0 Error(s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:23:53 -04:00
gamer147
c8314bd3c0 test(battle-engine-port): M6 COMPLETE — targeted when_play damage spell resolves headless (selection-routing oracle)
First card to exercise the selectedCards path of ActionProcessor.PlayCard
(dormant through M2-M5, all of which played selectedCards: null). Spell
800134020 (clan-1 cost-1, when_play damage=5 to a select_count=1 enemy
follower) resolves headless: with two vanilla followers on the enemy board
and one passed as selectedCards, the damage hits ONLY the selected follower
(13->8) and the un-selected one is untouched (7).

New oracle dimension: SELECTION ROUTING via a differential life-delta on two
surviving targets (selected -5, un-selected 0) — reads the authoritative
damage path M3 proved, with no dependence on follower death/board-removal
timing. Load-bearing confirmed (M4 discipline): swapping which follower is
selected makes the damage follow the selection (assertions fail for the right
reason), then reverted to green.

Like M4, a clean milestone: NO new engine/shim work — the selectedCards path
resolved on the existing shim surface. The only authoring was test-side: the
M6 card constants, a shared HeadlessEngineEnv.PutFollowerInPlay primitive
(create via the null-view seam + drive HandCardToField), and the oracle.

Engine still 0 errors; check_drift clean; dotnet test -> 6/6 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:08:01 -04:00
gamer147
62a28fe2d4 feat(battle-engine-port): M5 COMPLETE — summon_token spell resolves headless (board-count delta oracle)
Card 800134010 (clan-1 cost-1 ungated spell, summon_token=100011020): a when_play
summon places one new neutral 2/2 follower token on the caster board. New oracle
dimension = board-count + token-identity delta from a SKILL-CREATED card. 5/5 green;
engine 0 errors; check_drift clean; zero new Engine copies.

This is the first headless run of the PUBLIC prefab card-creation path
(CardCreatorBase.CreateCard, createNullView:false) — engine-internal card creation
(summon/draw/token) has no null-view path in solo mode, unlike the M2-M4 hand-card
seam. Built that path headless:
- Self-consistent no-op Unity object graph (UnityShim.cs): Component.gameObject/
  transform, GameObject.transform, Transform.parent/Find now lazily non-null +
  cached; GetComponent routed through the GameObject component model.
- Targeted NGUI material backing-field wiring (UIFont.mMat / UILabel.mMaterial) so
  the copied material getters return non-null via their simple branch (blanket/deep
  wiring would make them delegate down a re-nulling chain).
- getUIBase_CardManager() default! -> field-wired no-op via new ShimView.Create<T>().
- Test-side seeds: SBattleLoad card templates + 3D scene GameObjects (InitCardTemplates).

Load-bearing proof: swapping to the M3 non-summoning spell fails the board-count
(Expected 2, was 1) + token-not-found assertions; reverted to green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 03:19:47 -04:00
gamer147
b13cfa0fad 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>
2026-06-06 02:36:02 -04:00
gamer147
c47ae93027 feat(battle-engine-port): M3 COMPLETE — fixed-damage spell resolves headless (leader-life-delta oracle passes)
Card 900124030 (ELF cost-3, when_play damage=3 to enemy leader) resolves to
correct authoritative state headless via the IsForecast/IsRecovery +
ActionProcessor.PlayCard path. New oracle dimension (opponent leader-life delta)
passes; 3/3 tests green; engine still 0 errors; check_drift clean.

Four headless gaps, each mechanical (no logic/Unity wall):
- Data seam: InitLeaderLife (SetupInitialGameState->InitializeClassLife subset);
  leader BaseMaxLife was 0 => game-over => play silently rejected. M2 missed it
  (only asserted leader life unchanged: 0==0).
- Runtime cast: re-attach IClassBattleCardView on the generated
  NullClassBattleCardView stub (members already present; base-clause recovery
  stripped the decl). Compiled fine -> M1 loop never surfaced it.
- M1 mis-cut: copy NullVfxWithLoading verbatim (its GetInstance() lazy singleton
  was stubbed to default!/null). Same pattern as M2 NullCardVfxCreator.
- Card events: CreateHeadlessHandCard now calls SetupCardEvent so a spell's
  OnPlay->RemoveSpellCardFromHand / OnFinishWhenPlaySkill->AddSpellCardToCemetery
  fire (the bare CreateCardWithoutResources seam skips them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 02:19:54 -04:00
gamer147
171f07ec74 feat(battle-engine-port): M2 COMPLETE — vanilla follower resolves headless (go/no-go = GO)
First green: a zero-skill vanilla follower (100011010, neutral 1/2) resolves to
correct authoritative state HEADLESS via IsForecast/IsRecovery + ActionProcessor.
PlayCard (DP4), no Unity runtime. §5 oracle passes (PP-cost; hand->in-play;
atk/health == CardCSVData base; opponent unchanged; no exception). VERDICT: the
port approach is validated through the resolution path, not just M1's compile path.

VanillaFollowerOracleTests.Vanilla_follower_resolves_to_correct_state — GREEN.
HeadlessCardMaster now loads the follower's real id from cards.json.

Resolution-path shim/engine gaps closed (all mechanical no-op fills or data seams,
never a Unity/logic wall):
- M1 mis-cut copies (DP1/DP3 — pure no-op logic wrongly stubbed to null):
  Engine/Wizard.Battle.View.Vfx/NullCardVfxCreator.cs (its GetInstance() singleton
  was nulled) + its dep NotEmptyNullVfx.cs. Deleted the generated NullCardVfxCreator
  stub + its _IfaceImpl block; both manifested, check_drift clean.
- _IfaceImpl explicit-impl shadow: interface-typed view/mgr calls dispatch to the
  explicit impls (which returned default!), shadowing public stubs. Fixed
  IBattlePlayerView.GetSideLogControl (SkillProcessor side-log tail) to return a
  non-null no-op. KEY M3+ learning: fix _IfaceImpl.g.cs for interface-typed NREs.

(GameMgr/component-model/Resources/IClassBattleCardView shim fills + CardIconControl
copy + the SVSim.BattleEngine.Tests project landed in the prior commit 2b50657.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:57:15 -04:00
gamer147
2b506574e7 feat(battle-engine-port): M2 step 1 — SingleBattleMgr constructs headless
First green of the M2 go/no-go probe: `new SingleBattleMgr(StandardBattleMgr-
ContentsCreator)` now builds the two-player pair fully headless against the shim,
no Unity runtime. Verdict: headless construction is feasible; every blocker was a
mechanical no-op shim fill or data seam, not a Unity/logic wall.

Shim fills (authored):
- GameMgr: lazy non-null DataMgr/PrefabMgr/InputMgr/SoundMgr/BattleControl.
- GameObject: lazy cached component model so GetComponent<T>/AddComponent<T> return
  non-null no-op instances for Component-derived T (F1: unguarded view touches).
- Resources.Load(string): cached non-null GameObject so the prefab->Instantiate->
  GetComponent chain (UnityEventAgent) yields a real object.
- ClassBattleCardViewBase: re-attach dropped IClassBattleCardView (no-op members);
  ClassBattleCardBase.Setup casts the created view to it.

Engine copy (DP1/DP3 mis-cut fix):
- CardIconControl.cs copied verbatim (manifested) + generated null-stub deleted.
  SplitAndCompleteIconStr is pure string logic on the resolution path that M1 had
  wrongly stubbed as "View" -> null deref in SkillCreator.CreateBuildInfo.

Test harness (SVSim.BattleEngine.Tests, authored fixture):
- HeadlessContentsCreator/HeadlessPhaseCreator: deterministic replica of the solo
  practice init (StandardBattleMgrContentsCreator + SingleBattlePhaseCreator) with
  no-op recovery/replay managers.
- HeadlessCardMaster: reflects the loader cards.json dump into CardMaster.
- HeadlessMasterData: minimal Data.Master (class-character list, empty collections)
  + Data.Load + player/enemy chara ids.
- ConstructionProbeTests.SingleBattleMgr_constructs_headless — GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:36:22 -04:00