import { SkillProcessor } from './SkillProcessor.js'; import { CombatAI } from './CombatAI.js'; import { RNG } from '../utils/RNG.js'; const MAX_LANES = 4; const MAX_HAND = 3; export class CombatEngine { constructor(playerDeck, opponentDeck, cardManager, seed, playerLevel = 1, enemyLevel = 1) { this.cardManager = cardManager; this.skillProcessor = new SkillProcessor(); this.rng = new RNG(seed || Date.now()); // Player side this.playerCommander = cardManager.createInstance(playerDeck.commander, playerLevel); this.playerDeck = playerDeck.cards.map(id => cardManager.createInstance(id, playerLevel)); this.rng.shuffle(this.playerDeck); this.playerDeckIdx = 0; this.playerHand = []; this.playerLanes = []; // active cards in lanes // Opponent side (AI) this.ai = new CombatAI(opponentDeck, cardManager, seed ? seed + 1 : Date.now() + 1, enemyLevel); this.opponentCommander = this.ai.commander; this.opponentLanes = []; this.turn = 0; this.log = []; this.winner = null; // 'player' | 'opponent' | null this.events = []; // events for animation: [{type, data}] this.playerGoesFirst = true; // alternates each turn } _log(msg) { this.log.push(`Turn ${this.turn}: ${msg}`); } // Draw up to MAX_HAND cards _drawPhase() { while (this.playerHand.length < MAX_HAND && this.playerDeckIdx < this.playerDeck.length) { this.playerHand.push(this.playerDeck[this.playerDeckIdx++]); } this.ai.drawCards(Math.max(0, MAX_HAND - this.ai.hand.length)); } // Deploy one card from hand into next open lane _deployPhase() { if (this.playerHand.length > 0 && this.playerLanes.length < MAX_LANES) { const card = this.playerHand.shift(); this.playerLanes.push(card); this._log(`Player deploys ${card.name}`); this.events.push({ type: 'deploy', side: 'player', card }); } const aiCard = this.ai.getNextCard(); if (aiCard && this.opponentLanes.length < MAX_LANES) { this.opponentLanes.push(aiCard); this._log(`Opponent deploys ${aiCard.name}`); this.events.push({ type: 'deploy', side: 'opponent', card: aiCard }); } } // Tick delay counters — only cards already on the field, not hand cards. // Delay counts down each turn a card spends on the field, not in hand. _activationPhase() { const tick = cards => { for (const c of cards) { if (c.currentDelay > 0) c.currentDelay--; } }; tick(this.playerLanes); tick(this.opponentLanes); } // Apply rupture damage to all cards _rupturePhase() { const applyRupture = cards => { for (const c of cards) { if (c.ruptureStacks > 0) { const dmg = c.ruptureStacks; c.currentHP -= dmg; this._log(`${c.name} takes ${dmg} rupture damage`); this._tryCarapace(c, dmg); } } }; applyRupture(this.playerLanes); applyRupture(this.opponentLanes); } // If the card has carapace and just took damage, apply armor gain (with cap). // Pass emit=false to apply armor without pushing an event (for preAttack fires). _tryCarapace(card, dmg, emit = true) { if (dmg <= 0 || card.currentHP <= 0) return 0; const s = card.skills?.find(sk => sk.name === 'carapace'); if (!s) return 0; const cap = (card.baseArmor ?? 0) + 5 * s.value; if (card.currentArmor >= cap) return 0; const gain = Math.min(s.value, cap - card.currentArmor); card.currentArmor += gain; card.armor += gain; card.baseArmor += gain; this._log(`${card.name} carapace: +${gain} ARM (cap ${cap}, new baseArmor ${card.baseArmor})`); if (emit) this.events.push({ type: 'carapace', card, gain }); return gain; } // Apply venom damage to all cards _venomPhase() { const applyVenom = (cards, alliedLanes, side) => { for (const c of cards) { if (c.venomStacks > 0) { const dmg = c.venomStacks; c.currentHP -= dmg; this._log(`${c.name} takes ${dmg} venom damage`); const killed = c.currentHP <= 0; if (killed) { this._log(`${c.name} is destroyed by venom`); this.events.push({ type: 'death', card: c, side }); this._processOnDeath(c, alliedLanes, side); } this.events.push({ type: 'venomTick', card: c, damage: dmg, killed }); this._tryCarapace(c, dmg); c.venomStacks--; } } }; applyVenom(this.playerLanes, this.playerLanes, 'player'); applyVenom(this.opponentLanes, this.opponentLanes, 'opponent'); } // Apply smite damage to all cards (mirrors venom) _smitePhase() { const applySmite = (cards, alliedLanes, side) => { for (const c of cards) { if (c.smiteStacks > 0) { const dmg = c.smiteStacks; c.currentHP -= dmg; this._log(`${c.name} takes ${dmg} smite damage`); const killed = c.currentHP <= 0; if (killed) { this._log(`${c.name} is destroyed by smite`); this.events.push({ type: 'death', card: c, side }); this._processOnDeath(c, alliedLanes, side); } this.events.push({ type: 'smiteTick', card: c, damage: dmg, killed }); this._tryCarapace(c, dmg); c.smiteStacks--; } } }; applySmite(this.playerLanes, this.playerLanes, 'player'); applySmite(this.opponentLanes, this.opponentLanes, 'opponent'); } // Decrement burrow stacks at the end of each combat turn (postBattle phase). // Each card announces its new remaining stack count for animation. _burrowPhase() { const tickBurrow = (cards) => { for (const c of cards) { if (c.burrowTurns > 0) { c.burrowTurns--; this.events.push({ type: 'burrowTick', card: c, remaining: c.burrowTurns }); this._log(`${c.name} burrow tick: ${c.burrowTurns} turns remaining`); } } }; tickBurrow(this.playerLanes); tickBurrow(this.opponentLanes); } // Tick jam timers _jamPhase() { const tickJam = cards => { for (const c of cards) { if (c.jamTurns > 0) c.jamTurns--; } }; tickJam(this.playerLanes); tickJam(this.opponentLanes); } // Fire on_turn_start skills for commanders _commanderSkillPhase() { const processCommander = (commander, lanes, enemyCommander, enemyLanes) => { if (commander.currentHP <= 0) return; for (const s of commander.skills) { if (s.trigger !== 'on_turn_start') continue; const ctx = { enemyCommander, cardManager: this.cardManager, laneCards: lanes }; this.skillProcessor.process(s, commander, null, [commander, ...lanes], [enemyCommander, ...enemyLanes], ctx); if (ctx.spawnedDrone && lanes.length < MAX_LANES) { lanes.push(ctx.spawnedDrone); const side = (commander === this.playerCommander) ? 'player' : 'opponent'; this._log(`${commander.name} spawns a ${ctx.spawnedDrone.name}`); this.events.push({ type: 'spawn', side, card: ctx.spawnedDrone }); } } }; processCommander(this.playerCommander, this.playerLanes, this.opponentCommander, this.opponentLanes); processCommander(this.opponentCommander, this.opponentLanes, this.playerCommander, this.playerLanes); } // Fire on_turn_start skills for lane cards (molt is now handled in _moltPhase during preBattle) _cardOnTurnStartPhase() { const processCards = (cards, commander, enemyCommander, enemyLanes) => { for (const card of cards) { if (card.currentHP <= 0 || card.currentDelay > 0) continue; for (const s of card.skills) { if (s.trigger !== 'on_turn_start') continue; if (s.name === 'molt') continue; // molt now fires in _moltPhase (preBattle) const ctx = { enemyCommander, cardManager: this.cardManager, laneCards: cards }; this.skillProcessor.process(s, card, null, [commander, ...cards], [enemyCommander, ...enemyLanes], ctx); } } }; processCards(this.playerLanes, this.playerCommander, this.opponentCommander, this.opponentLanes); processCards(this.opponentLanes, this.opponentCommander, this.playerCommander, this.playerLanes); } // Fire molt for all lane cards that are injured and have armor — runs during preBattle. _moltPhase() { const applyMolt = (cards, commander, enemyCommander, enemyLanes) => { for (const card of cards) { if (card.currentHP <= 0 || card.currentDelay > 0) continue; for (const s of card.skills) { if (s.name !== 'molt') continue; if (card.currentHP >= card.health || card.currentArmor <= 0) continue; const ctx = { enemyCommander, cardManager: this.cardManager, laneCards: cards }; this.skillProcessor.process(s, card, null, [commander, ...cards], [enemyCommander, ...enemyLanes], ctx); if (ctx.moltHeal > 0) { this._log(`${card.name} molts: heals ${ctx.moltHeal}, loses ${ctx.moltArmorLost} armor`); this.events.push({ type: 'molt', card, heal: ctx.moltHeal, armorLost: ctx.moltArmorLost }); } } } }; applyMolt(this.playerLanes, this.playerCommander, this.opponentCommander, this.opponentLanes); applyMolt(this.opponentLanes, this.opponentCommander, this.playerCommander, this.playerLanes); } // Cards attack across lanes _attackPhase() { // Player cards attack opponent lanes / commander for (let i = 0; i < this.playerLanes.length; i++) { const attacker = this.playerLanes[i]; if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue; const target = this.opponentLanes[i] || null; this._performAttack(attacker, target, this.opponentCommander, this.playerLanes, this.opponentLanes, 'player'); } // Opponent cards attack player lanes / commander for (let i = 0; i < this.opponentLanes.length; i++) { const attacker = this.opponentLanes[i]; if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue; const target = this.playerLanes[i] || null; this._performAttack(attacker, target, this.playerCommander, this.opponentLanes, this.playerLanes, 'opponent'); } } _performAttack(attacker, target, enemyCommander, alliedLanes, enemyLanes, side) { const context = { enemyCommander, cardManager: this.cardManager, attackingCard: attacker }; // Pre-attack passive/on_attack skills (flurry, valor — pierce now fires in preAttack phase) for (const s of attacker.skills) { if (['flurry', 'valor'].includes(s.name) && ['on_attack', 'passive'].includes(s.trigger)) { const passiveCtx = { ...context }; this.skillProcessor.process(s, attacker, target, alliedLanes.filter(c => c.currentHP > 0), enemyLanes.filter(c => c.currentHP > 0), passiveCtx); if (passiveCtx.extraAttacks) context.extraAttacks = passiveCtx.extraAttacks; } } const extraAttacks = (context.extraAttacks || 0) + 1; for (let a = 0; a < extraAttacks; a++) { // Skip a target that was already killed (e.g. by mortar during preAttack) const currentTarget = (target && target.currentHP > 0) ? target : null; if (currentTarget) { // Attack the lane card (pierce has already reduced currentArmor during preAttack) const effectiveArmor = Math.max(0, currentTarget.currentArmor); let dmg = Math.max(0, attacker.currentAttack - effectiveArmor); // armored skill on defender const armorSkill = currentTarget.skills.find(s => s.name === 'armored'); if (armorSkill) dmg = Math.max(0, dmg - armorSkill.value); // burrowed cards take half damage from direct attacks if (currentTarget.burrowTurns > 0) dmg = Math.floor(dmg / 2); currentTarget.currentHP -= dmg; this._log(`${attacker.name} attacks ${currentTarget.name} for ${dmg}`); this.events.push({ type: 'attack', attacker, defender: currentTarget, damage: dmg, side }); // on_attack skills for (const s of attacker.skills) { if (s.trigger === 'on_attack') { const onAtkCtx = { ...context, trigger: 'on_attack', damageDealt: dmg }; this.skillProcessor.process(s, attacker, currentTarget, alliedLanes.filter(c => c.currentHP > 0), enemyLanes.filter(c => c.currentHP > 0), onAtkCtx); if (onAtkCtx.ruptureAllTargets) { this.events.push({ type: 'ruptureAll', attacker, targets: onAtkCtx.ruptureAllTargets, side }); } if (onAtkCtx.weakenAllTargets) { this.events.push({ type: 'weakenAll', attacker, targets: onAtkCtx.weakenAllTargets, side }); } if (onAtkCtx.venomTarget) { this.events.push({ type: 'venomApply', attacker, target: onAtkCtx.venomTarget, stacks: onAtkCtx.venomStacks, side }); } if (onAtkCtx.venomAllTargets) { this.events.push({ type: 'venomApplyAll', attacker, targets: onAtkCtx.venomAllTargets, side }); } if (onAtkCtx.smiteTarget) { this.events.push({ type: 'smiteApply', attacker, target: onAtkCtx.smiteTarget, stacks: onAtkCtx.smiteStacks, side }); } if (onAtkCtx.smiteAllTargets) { this.events.push({ type: 'smiteApplyAll', attacker, targets: onAtkCtx.smiteAllTargets, side }); } } } // Emit berserk event if the attacker has berserk and dealt damage if (dmg > 0) { for (const s of attacker.skills) { if (s.name === 'berserk') { this.events.push({ type: 'berserk', card: attacker, gain: s.value }); } } } // counter and carapace skills on defender — only fire if defender survived the main attack if (currentTarget.currentHP > 0) { for (const s of currentTarget.skills) { if (s.name === 'counter') { const counterCtx = { ...context, attackingCard: attacker, trigger: 'on_defend' }; this.skillProcessor.process(s, currentTarget, attacker, enemyLanes.filter(c => c.currentHP > 0), alliedLanes.filter(c => c.currentHP > 0), counterCtx); if (counterCtx.counterDamage >= 0) { this._log(`${currentTarget.name} counters ${attacker.name} for ${counterCtx.counterDamage}`); this.events.push({ type: 'counter', source: currentTarget, target: attacker, damage: counterCtx.counterDamage }); if (attacker.currentHP <= 0) { this._log(`${attacker.name} is destroyed by counter`); this.events.push({ type: 'death', card: attacker, side }); this._processOnDeath(attacker, alliedLanes, side); } } } if (s.name === 'carapace') { this._tryCarapace(currentTarget, dmg); } } } // Check if defender died if (currentTarget.currentHP <= 0) { this._log(`${currentTarget.name} is destroyed`); const defenderSide = side === 'player' ? 'opponent' : 'player'; this.events.push({ type: 'death', card: currentTarget, side: defenderSide }); this._processOnDeath(currentTarget, enemyLanes, defenderSide); } } else { // Attack commander directly (pierce has already reduced currentArmor if applicable) const effectiveArmor = Math.max(0, enemyCommander.currentArmor); const dmg = Math.max(0, attacker.currentAttack - effectiveArmor); enemyCommander.currentHP -= dmg; this._log(`${attacker.name} attacks ${enemyCommander.name} for ${dmg}`); this.events.push({ type: 'attack', attacker, defender: enemyCommander, damage: dmg, side }); // on_attack skills that hit commander directly too for (const s of attacker.skills) { if (s.trigger === 'on_attack' && s.name === 'strike') { this.skillProcessor.process(s, attacker, enemyCommander, alliedLanes, enemyLanes, { ...context, trigger: 'on_attack' }); } } } } } // Process on_death skills for a card that just died _processOnDeath(deadCard, alliedLanes, side) { for (const s of deadCard.skills) { if (s.trigger !== 'on_death' && s.name !== 'hive_link') continue; const allies = alliedLanes.filter(a => a.currentHP > 0); const ctx = {}; this.skillProcessor.process(s, deadCard, null, allies, [], ctx); if (s.name === 'hive_link' && ctx.hiveLinkTargets?.length > 0) { this._log(`${deadCard.name} hive_link: +${s.value} ATK to ${ctx.hiveLinkTargets.length} allies`); this.events.push({ type: 'hive_link', source: deadCard, targets: ctx.hiveLinkTargets, gain: s.value, side }); } } } // Remove dead cards, compact lanes _deathCheck() { this.playerLanes = this.playerLanes.filter(c => c.currentHP > 0); this.opponentLanes = this.opponentLanes.filter(c => c.currentHP > 0); } _winCheck() { if (this.playerCommander.currentHP <= 0) { this.winner = 'opponent'; this._log('Player commander destroyed — Opponent wins!'); return true; } if (this.opponentCommander.currentHP <= 0) { this.winner = 'player'; this._log('Opponent commander destroyed — Player wins!'); return true; } // Check if both sides are out of cards if (this.playerDeckIdx >= this.playerDeck.length && this.playerHand.length === 0 && this.playerLanes.length === 0) { this.winner = 'opponent'; this._log('Player ran out of cards — Opponent wins!'); return true; } return false; } // Phase 1: draw cards, AI deploys (only when opponent goes first), return player's hand beginTurn() { if (this.winner) return { hand: [], canDeploy: false, playerGoesFirst: this.playerGoesFirst }; this.events = []; this.turn++; this._log(`--- Turn ${this.turn} ---`); this._drawPhase(); // AI deploys immediately only on opponent-first turns if (!this.playerGoesFirst) { const aiCard = this.ai.getNextCard(); if (aiCard && this.opponentLanes.length < MAX_LANES) { this.opponentLanes.push(aiCard); this._log(`Opponent deploys ${aiCard.name}`); this.events.push({ type: 'deploy', side: 'opponent', card: aiCard }); } } const canDeploy = this.playerHand.length > 0 && this.playerLanes.length < MAX_LANES; return { hand: [...this.playerHand], canDeploy, playerGoesFirst: this.playerGoesFirst }; } // Phase 2: deploy chosen card (or null to pass), then resolve the turn commitPlayerDeploy(chosenCard) { this._activationPhase(); if (chosenCard && this.playerLanes.length < MAX_LANES) { const idx = this.playerHand.findIndex(c => c.instanceId === chosenCard.instanceId); if (idx !== -1) { this.playerHand.splice(idx, 1); this.playerLanes.push(chosenCard); this._log(`Player deploys ${chosenCard.name}`); this.events.push({ type: 'deploy', side: 'player', card: chosenCard }); } } for (const c of this.playerHand) { if (c.currentDelay > 0) c.currentDelay--; } for (const c of this.ai.hand) { if (c.currentDelay > 0) c.currentDelay--; } this._commanderSkillPhase(); this._cardOnTurnStartPhase(); this._rupturePhase(); this._venomPhase(); this._smitePhase(); this._moltPhase(); this._jamPhase(); this._attackPhase(); this._deathCheck(); this._winCheck(); this._burrowPhase(); return [...this.events]; } // ── Step-by-step combat API (used by animated battle scene) ────────────── // Deploy the chosen card and run all pre-attack phases. // Does NOT resolve attacks yet — call processNextAttack() for each one. beginCommit(chosenCard) { this.events = []; // Tick field cards BEFORE deploying so newly deployed cards don't lose a delay // tick the same turn they enter the field. this._activationPhase(); if (chosenCard && this.playerLanes.length < MAX_LANES) { const idx = this.playerHand.findIndex(c => c.instanceId === chosenCard.instanceId); if (idx !== -1) { this.playerHand.splice(idx, 1); this.playerLanes.push(chosenCard); this._log(`Player deploys ${chosenCard.name}`); this.events.push({ type: 'deploy', side: 'player', card: chosenCard }); } } // Tick hand cards that were NOT deployed this turn — the deployed card has already // been removed from playerHand above so it won't be double-ticked. for (const c of this.playerHand) { if (c.currentDelay > 0) c.currentDelay--; } // On player-first turns, AI deploys after the player if (this.playerGoesFirst) { const aiCard = this.ai.getNextCard(); if (aiCard && this.opponentLanes.length < MAX_LANES) { this.opponentLanes.push(aiCard); this._log(`Opponent deploys ${aiCard.name}`); this.events.push({ type: 'deploy', side: 'opponent', card: aiCard }); } } // Tick AI hand cards that were not deployed this turn for (const c of this.ai.hand) { if (c.currentDelay > 0) c.currentDelay--; } this._commanderSkillPhase(); this._cardOnTurnStartPhase(); this._rupturePhase(); this._deathCheck(); // remove cards killed by rupture before combat this._jamPhase(); this._preBattlePhase(); // _venomPhase() is called first inside _preBattlePhase // NOTE: No _deathCheck() here — dead cards stay in lanes so the scene can // animate their death during preBattle (e.g. drain kills). _buildPendingAttacks // already skips dead cards via currentHP <= 0 checks. finalizeCommit() calls // _deathCheck() after all animations are done. this._buildPendingAttacks(); this.playerGoesFirst = !this.playerGoesFirst; // flip initiative for next turn return [...this.events]; } // Process preBattle skills for a set of cards and return buff, siege fire, protect fire, and enfeeble fire descriptors. _collectPreBattleFires(cards, allies, enemies, enemyCommander, enemyLanes = []) { const buffs = []; const siegeFires = []; const protectFires = []; const enfeebeFires = []; const jamFires = []; const drainFires = []; const healFires = []; const sanctifyFires = []; const overchargeFires = []; const fortifyFires = []; const liveAllies = allies.filter(c => c.currentHP > 0); const liveEnemies = enemies.filter(c => c.currentHP > 0); for (const card of cards) { if (card.currentHP <= 0) continue; if (card.currentDelay > 0) continue; for (const s of card.skills) { if (s.trigger !== 'preBattle') continue; // For drain, resolve the card directly across as defender const cardIdx = cards.indexOf(card); const acrossCard = enemyLanes[cardIdx]; const drainDefender = (s.name === 'drain' && acrossCard?.currentHP > 0) ? acrossCard : null; // Pass laneCards + enemyLaneCards so positional skills (protect, enfeeble) work const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes }; this.skillProcessor.process(s, card, drainDefender, liveAllies, liveEnemies, ctx); if (s.name === 'rally' && ctx.rallyAllTargets) { for (const t of ctx.rallyAllTargets) { buffs.push({ skill: 'rally', source: card, target: t.target, amount: t.amount, isAll: true }); } } else if (s.name === 'rally' && ctx.rallyTarget) { buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value }); } if (s.name === 'legion' && ctx.legionCard) { buffs.push({ skill: 'legion', source: card, target: card, amount: ctx.legionGain }); this._log(`${card.name} legion: +${ctx.legionGain} ATK`); } // swarm now fires in preAttack phase, not preBattle if (s.name === 'bloodrage' && ctx.bloodrageGain) { buffs.push({ skill: 'bloodrage', source: card, target: card, amount: ctx.bloodrageGain }); this._log(`${card.name} bloodrage: +${ctx.bloodrageGain} ATK`); } if (s.name === 'siege' && ctx.siegeTarget) { const hpBefore = ctx.siegeTarget.currentHP + ctx.siegeDamage; siegeFires.push({ skill: 'siege', source: card, target: ctx.siegeTarget, damage: ctx.siegeDamage, hpBefore }); this._log(`${card.name} siege hits ${ctx.siegeTarget.name} for ${ctx.siegeDamage}`); } if (s.name === 'protect' && ctx.protectTargets?.length) { protectFires.push({ skill: 'protect', source: card, targets: ctx.protectTargets }); this._log(`${card.name} protect buffs ${ctx.protectTargets.map(t => t.card.name).join(', ')} +${s.value} ARM`); } if (s.name === 'enfeeble' && ctx.enfeebleAllTargets) { for (const t of ctx.enfeebleAllTargets) { enfeebeFires.push({ skill: 'enfeeble', source: card, target: t.target, amount: t.amount, isAll: true }); this._log(`${card.name} enfeeble all reduces ${t.target.name} ATK by ${t.amount}`); } } else if (s.name === 'enfeeble' && ctx.enfeebleTarget) { enfeebeFires.push({ skill: 'enfeeble', source: card, target: ctx.enfeebleTarget, amount: ctx.enfeebleAmount }); this._log(`${card.name} enfeeble reduces ${ctx.enfeebleTarget.name} ATK by ${ctx.enfeebleAmount}`); } if (s.name === 'jam' && ctx.jamTargets?.length) { jamFires.push({ skill: 'jam', source: card, targets: ctx.jamTargets }); for (const t of ctx.jamTargets) { this._log(`${card.name} jam suppresses ${t.card.name} skills by ${t.reductions.map(r => r.amount).join('/')}`); } } if (s.name === 'drain' && ctx.drainTarget) { const hpBefore = ctx.drainTarget.currentHP + ctx.drainDamage; const targetIsCommander = ctx.drainTarget === enemyCommander; const secondaries = (ctx.drainSecondaries || []).map(sec => ({ target: sec.target, damage: sec.damage, hpBefore: sec.target.currentHP + sec.damage, laneOffset: sec.laneOffset })); drainFires.push({ skill: 'drain', source: card, target: ctx.drainTarget, damage: ctx.drainDamage, hpBefore, targetIsCommander, heal: ctx.drainHeal, secondaries }); this._log(`${card.name} drains ${ctx.drainTarget.name} for ${ctx.drainDamage}`); const _drainerIsPlayer = this.playerLanes.includes(card) || card === this.playerCommander; if (!targetIsCommander && ctx.drainTarget.currentHP <= 0) { this._log(`${ctx.drainTarget.name} is destroyed by drain`); const _deadSide = _drainerIsPlayer ? 'opponent' : 'player'; const _deadAllies = _drainerIsPlayer ? this.opponentLanes : this.playerLanes; this.events.push({ type: 'death', card: ctx.drainTarget, side: _deadSide }); this._processOnDeath(ctx.drainTarget, _deadAllies, _deadSide); } for (const sec of secondaries) { this._log(`${card.name} drain splashes ${sec.target.name} for ${sec.damage}`); if (sec.target.currentHP <= 0) { this._log(`${sec.target.name} is destroyed by drain splash`); const _deadSide = _drainerIsPlayer ? 'opponent' : 'player'; const _deadAllies = _drainerIsPlayer ? this.opponentLanes : this.playerLanes; this.events.push({ type: 'death', card: sec.target, side: _deadSide }); this._processOnDeath(sec.target, _deadAllies, _deadSide); } } } if (s.name === 'heal' && ctx.healAllTargets) { healFires.push({ skill: 'heal', source: card, isAll: true, targets: ctx.healAllTargets }); this._log(`${card.name} heal all: restored HP to ${ctx.healAllTargets.length} allies`); } else if (s.name === 'heal' && ctx.healTarget) { healFires.push({ skill: 'heal', source: card, target: ctx.healTarget, healAmount: ctx.healAmount, hpBefore: ctx.healHpBefore }); this._log(`${card.name} heal: restored ${ctx.healAmount} HP to ${ctx.healTarget.name}`); } if (s.name === 'sanctify' && ctx.sanctifyAllTargets) { sanctifyFires.push({ skill: 'sanctify', source: card, isAll: true, targets: ctx.sanctifyAllTargets }); this._log(`${card.name} sanctify all: cleansed ${ctx.sanctifyAllTargets.length} allies`); } else if (s.name === 'sanctify' && ctx.sanctifyCleansed?.length) { sanctifyFires.push({ skill: 'sanctify', source: card, target: ctx.sanctifyTarget, cleansed: ctx.sanctifyCleansed }); this._log(`${card.name} sanctify: cleansed self`); } if (s.name === 'overcharge' && ctx.overchargeSource) { const selfKilled = ctx.overchargeSelfKilled; overchargeFires.push({ skill: 'overcharge', source: card, selfDamage: ctx.overchargeSelfDamage, selfKilled, targets: ctx.overchargeTargets }); this._log(`${card.name} overcharge: -${ctx.overchargeSelfDamage} HP to self, +${ctx.overchargeSelfDamage} ATK to ${ctx.overchargeTargets.length} allies`); if (selfKilled) { this._log(`${card.name} is destroyed by overcharge`); const isPlayerCard = this.playerLanes.includes(card) || card === this.playerCommander; const deadSide = isPlayerCard ? 'player' : 'opponent'; const deadAllies = isPlayerCard ? this.playerLanes : this.opponentLanes; this.events.push({ type: 'death', card, side: deadSide }); this._processOnDeath(card, deadAllies, deadSide); } } if (s.name === 'fortify' && ctx.fortifyAllTargets) { fortifyFires.push({ skill: 'fortify', source: card, isAll: true, targets: ctx.fortifyAllTargets }); this._log(`${card.name} fortify all: +${s.value} ARM to ${ctx.fortifyAllTargets.length} allies`); } else if (s.name === 'fortify' && ctx.fortifyTarget) { fortifyFires.push({ skill: 'fortify', source: card, target: ctx.fortifyTarget, amount: ctx.fortifyAmount }); this._log(`${card.name} fortify: +${ctx.fortifyAmount} ARM to self`); } } } return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires, healFires, sanctifyFires, overchargeFires, fortifyFires }; } // Emit the 8-step pre-battle sequence and process preBattle skills. _preBattlePhase() { // Venom/smite ticks first, then molt heals (both before buff skills). this._venomPhase(); this._smitePhase(); this._moltPhase(); const firstSide = this.playerGoesFirst ? 'player' : 'opponent'; const otherSide = this.playerGoesFirst ? 'opponent' : 'player'; const firstCmd = this.playerGoesFirst ? this.playerCommander : this.opponentCommander; const otherCmd = this.playerGoesFirst ? this.opponentCommander : this.playerCommander; const firstLanes = this.playerGoesFirst ? [...this.playerLanes] : [...this.opponentLanes]; const otherLanes = this.playerGoesFirst ? [...this.opponentLanes] : [...this.playerLanes]; const firstAllies = [firstCmd, ...firstLanes]; const otherAllies = [otherCmd, ...otherLanes]; // Steps 1–2: commander defensive buffs (placeholder — no defensive skills yet) this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, buffs: [], siegeFires: [], protectFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] }); this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] }); // Steps 3–4: commander offensive buffs (pass actual enemy lanes so all-targeting skills work) const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, otherLanes); const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, firstLanes); this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, ...firstCmdFires }); this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, ...otherCmdFires }); // Steps 5–6: lane defensive buffs (placeholder) this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] }); this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [], healFires: [], sanctifyFires: [], overchargeFires: [], fortifyFires: [] }); // Steps 7–8: lane offensive buffs + all skill fires const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes); const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes); this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, ...firstLaneFires }); this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, ...otherLaneFires }); } // Build the ordered list of attacks for this turn (called by beginCommit). // Attack order respects the current initiative (playerGoesFirst, before it is flipped). _buildPendingAttacks() { this._pendingAttacks = []; const addAttacks = (attackerLanes, enemyLanes, enemyCommander, alliedLanes, side) => { for (let i = 0; i < attackerLanes.length; i++) { const attacker = attackerLanes[i]; if (attacker.currentHP <= 0 || attacker.currentDelay > 0 || attacker.jamTurns > 0) continue; this._pendingAttacks.push({ attacker, target: enemyLanes[i] || null, alliedLanes, enemyLanes, enemyCommander, side }); } }; if (this.playerGoesFirst) { addAttacks(this.playerLanes, this.opponentLanes, this.opponentCommander, this.playerLanes, 'player'); addAttacks(this.opponentLanes, this.playerLanes, this.playerCommander, this.opponentLanes, 'opponent'); } else { addAttacks(this.opponentLanes, this.playerLanes, this.playerCommander, this.opponentLanes, 'opponent'); addAttacks(this.playerLanes, this.opponentLanes, this.opponentCommander, this.playerLanes, 'player'); } } // Execute exactly one pending attack and return its events. // Returns [] if the attacker died from a previous counter-attack this turn. processNextAttack() { if (!this._pendingAttacks || this._pendingAttacks.length === 0) return []; const pending = this._pendingAttacks.shift(); if (pending.attacker.currentHP <= 0) return this.processNextAttack(); this.events = []; // --- preAttack skill phase --- // Process skills with trigger === 'preAttack' (e.g. mortar) before damage resolves. const preAttackFires = []; const liveEnemies = pending.enemyLanes.filter(c => c.currentHP > 0); const liveAllies = pending.alliedLanes.filter(c => c.currentHP > 0); for (const s of pending.attacker.skills) { if (s.trigger !== 'preAttack') continue; const ctx = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes }; this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx); const _enemySide = pending.side === 'player' ? 'opponent' : 'player'; if (s.name === 'mortar' && ctx.mortarAllTargets) { for (const t of ctx.mortarAllTargets) { const mortarAllFire = { skill: 'mortar', target: t.target, damage: t.damage, hpBefore: t.hpBefore, isAll: true }; if (t.target.currentHP <= 0) { this._log(`${t.target.name} is destroyed by mortar all`); this.events.push({ type: 'death', card: t.target, side: _enemySide }); this._processOnDeath(t.target, pending.enemyLanes, _enemySide); } mortarAllFire.carapaceGain = this._tryCarapace(t.target, t.damage, false); preAttackFires.push(mortarAllFire); } } else if (s.name === 'mortar' && ctx.mortarTarget) { const hpBefore = ctx.mortarTarget.currentHP + ctx.mortarDamage; const mortarFire = { skill: 'mortar', target: ctx.mortarTarget, damage: ctx.mortarDamage, hpBefore }; if (ctx.mortarTarget.currentHP <= 0) { this._log(`${ctx.mortarTarget.name} is destroyed by mortar`); this.events.push({ type: 'death', card: ctx.mortarTarget, side: _enemySide }); this._processOnDeath(ctx.mortarTarget, pending.enemyLanes, _enemySide); } mortarFire.carapaceGain = this._tryCarapace(ctx.mortarTarget, ctx.mortarDamage, false); preAttackFires.push(mortarFire); } if (s.name === 'strike' && ctx.strikeAllTargets) { for (const t of ctx.strikeAllTargets) { const targetIsCommander = t.target === pending.enemyCommander; const strikeAllFire = { skill: 'strike', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander, isAll: true }; if (!targetIsCommander && t.target.currentHP <= 0) { this._log(`${t.target.name} is destroyed by strike all`); this.events.push({ type: 'death', card: t.target, side: _enemySide }); this._processOnDeath(t.target, pending.enemyLanes, _enemySide); } strikeAllFire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0; preAttackFires.push(strikeAllFire); } } else if (s.name === 'strike' && ctx.strikeTarget) { const hpBefore = ctx.strikeTarget.currentHP + ctx.strikeDamage; const targetIsCommander = ctx.strikeTarget === pending.enemyCommander; const strikeFire = { skill: 'strike', target: ctx.strikeTarget, damage: ctx.strikeDamage, hpBefore, targetIsCommander }; if (!targetIsCommander && ctx.strikeTarget.currentHP <= 0) { this._log(`${ctx.strikeTarget.name} is destroyed by strike`); this.events.push({ type: 'death', card: ctx.strikeTarget, side: _enemySide }); this._processOnDeath(ctx.strikeTarget, pending.enemyLanes, _enemySide); } strikeFire.carapaceGain = !targetIsCommander ? this._tryCarapace(ctx.strikeTarget, ctx.strikeDamage, false) : 0; preAttackFires.push(strikeFire); } if (s.name === 'swipe' && ctx.swipeAllTargets) { for (const t of ctx.swipeAllTargets) { const targetIsCommander = t.target === pending.enemyCommander; const swipeFire = { skill: 'swipe', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander }; if (!targetIsCommander && t.target.currentHP <= 0) { this._log(`${t.target.name} is destroyed by swipe`); this.events.push({ type: 'death', card: t.target, side: _enemySide }); this._processOnDeath(t.target, pending.enemyLanes, _enemySide); } swipeFire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0; preAttackFires.push(swipeFire); } } if (s.name === 'pierce' && ctx.pierceAllTargets) { for (const t of ctx.pierceAllTargets) { const targetIsCommander = t.target === pending.enemyCommander; preAttackFires.push({ skill: 'pierce', target: t.target, amount: t.amount, armBefore: t.armBefore, targetIsCommander, isAll: true }); this._log(`${pending.attacker.name} pierce all reduces ${t.target.name} ARM by ${t.amount}`); } } else if (s.name === 'pierce' && ctx.pierceTarget) { const armBefore = ctx.pierceTarget.currentArmor + ctx.pierceAmount; const targetIsCommander = ctx.pierceTarget === pending.enemyCommander; preAttackFires.push({ skill: 'pierce', target: ctx.pierceTarget, amount: ctx.pierceAmount, armBefore, targetIsCommander }); this._log(`${pending.attacker.name} pierce reduces ${ctx.pierceTarget.name} ARM by ${ctx.pierceAmount}`); } if (s.name === 'siphon' && ctx.siphonHeal != null) { preAttackFires.push({ skill: 'siphon', attacker: pending.attacker, target: pending.target, heal: ctx.siphonHeal }); this._log(`${pending.attacker.name} siphons ${ctx.siphonHeal} HP`); } if (s.name === 'bloodpact' && ctx.bloodpactCard) { preAttackFires.push({ skill: 'bloodpact', value: ctx.bloodpactValue, hpCost: ctx.bloodpactHPCost }); this._log(`${pending.attacker.name} bloodpact: -${ctx.bloodpactHPCost} HP, +${ctx.bloodpactValue} ATK`); if (pending.attacker.currentHP <= 0) { this.events.push({ type: 'death', card: pending.attacker, side: pending.side }); } } if (s.name === 'swarm' && ctx.swarmCard) { preAttackFires.push({ skill: 'swarm', card: pending.attacker, gain: ctx.swarmGain }); this._log(`${pending.attacker.name} swarm: +${ctx.swarmGain} ATK`); } } // Hack: copy opposing card's preAttack skills and fire them at hack value const hackSkill = pending.attacker.skills.find(s => s.name === 'hack' && s.trigger === 'preAttack'); if (hackSkill && pending.target?.currentHP > 0) { const hackCtx = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes }; this.skillProcessor.process(hackSkill, pending.attacker, pending.target, liveAllies, liveEnemies, hackCtx); if (hackCtx.hackCopiedSkills?.length > 0) { const hackFires = []; const _enemySide = pending.side === 'player' ? 'opponent' : 'player'; for (const copied of hackCtx.hackCopiedSkills) { const strikeMultiplied = copied.name === 'strike'; const syntheticSkill = { ...copied, value: strikeMultiplied ? hackSkill.value * 3 : hackSkill.value }; const ctx2 = { rng: this.rng, enemyCommander: pending.enemyCommander, enemyLaneCards: pending.enemyLanes }; this.skillProcessor.process(syntheticSkill, pending.attacker, pending.target, liveAllies, liveEnemies, ctx2); // Process results same as normal preAttack skills if (copied.name === 'strike' && ctx2.strikeTarget) { const hpBefore = ctx2.strikeTarget.currentHP + ctx2.strikeDamage; const targetIsCommander = ctx2.strikeTarget === pending.enemyCommander; const fire = { skill: 'strike', target: ctx2.strikeTarget, damage: ctx2.strikeDamage, hpBefore, targetIsCommander, isHacked: true }; if (!targetIsCommander && ctx2.strikeTarget.currentHP <= 0) { this.events.push({ type: 'death', card: ctx2.strikeTarget, side: _enemySide }); this._processOnDeath(ctx2.strikeTarget, pending.enemyLanes, _enemySide); } fire.carapaceGain = !targetIsCommander ? this._tryCarapace(ctx2.strikeTarget, ctx2.strikeDamage, false) : 0; hackFires.push(fire); } if (copied.name === 'mortar' && ctx2.mortarTarget) { const hpBefore = ctx2.mortarTarget.currentHP + ctx2.mortarDamage; const fire = { skill: 'mortar', target: ctx2.mortarTarget, damage: ctx2.mortarDamage, hpBefore, isHacked: true }; if (ctx2.mortarTarget.currentHP <= 0) { this.events.push({ type: 'death', card: ctx2.mortarTarget, side: _enemySide }); this._processOnDeath(ctx2.mortarTarget, pending.enemyLanes, _enemySide); } fire.carapaceGain = this._tryCarapace(ctx2.mortarTarget, ctx2.mortarDamage, false); hackFires.push(fire); } if (copied.name === 'swipe' && ctx2.swipeAllTargets) { for (const t of ctx2.swipeAllTargets) { const targetIsCommander = t.target === pending.enemyCommander; const fire = { skill: 'swipe', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander, isHacked: true }; if (!targetIsCommander && t.target.currentHP <= 0) { this.events.push({ type: 'death', card: t.target, side: _enemySide }); this._processOnDeath(t.target, pending.enemyLanes, _enemySide); } fire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0; hackFires.push(fire); } } if (copied.name === 'pierce' && ctx2.pierceTarget) { const armBefore = ctx2.pierceTarget.currentArmor + ctx2.pierceAmount; const targetIsCommander = ctx2.pierceTarget === pending.enemyCommander; hackFires.push({ skill: 'pierce', target: ctx2.pierceTarget, amount: ctx2.pierceAmount, armBefore, targetIsCommander, isHacked: true }); } if (copied.name === 'drain' && ctx2.drainTarget) { hackFires.push({ skill: 'drain', source: pending.attacker, target: ctx2.drainTarget, damage: ctx2.drainDamage, heal: ctx2.drainHeal, isHacked: true }); } if (copied.name === 'strike' && copied.all && ctx2.strikeAllTargets) { for (const t of ctx2.strikeAllTargets) { const targetIsCommander = t.target === pending.enemyCommander; const fire = { skill: 'strikeAll', target: t.target, damage: t.damage, hpBefore: t.hpBefore, targetIsCommander, isHacked: true }; if (!targetIsCommander && t.target.currentHP <= 0) { this.events.push({ type: 'death', card: t.target, side: _enemySide }); this._processOnDeath(t.target, pending.enemyLanes, _enemySide); } fire.carapaceGain = !targetIsCommander ? this._tryCarapace(t.target, t.damage, false) : 0; hackFires.push(fire); } } if (copied.name === 'jam' && ctx2.jamTargets?.length) { hackFires.push({ skill: 'jam', source: pending.attacker, targets: ctx2.jamTargets, isHacked: true }); } if (copied.name === 'venom' && !copied.all && ctx2.venomTarget) { hackFires.push({ skill: 'venom', attacker: pending.attacker, target: ctx2.venomTarget, stacks: ctx2.venomStacks, isHacked: true }); } if (copied.name === 'venom' && copied.all && ctx2.venomAllTargets?.length) { hackFires.push({ skill: 'venomAll', attacker: pending.attacker, targets: ctx2.venomAllTargets, isHacked: true }); } if (copied.name === 'smite' && !copied.all && ctx2.smiteTarget) { hackFires.push({ skill: 'smite', attacker: pending.attacker, target: ctx2.smiteTarget, stacks: ctx2.smiteStacks, isHacked: true }); } if (copied.name === 'smite' && copied.all && ctx2.smiteAllTargets?.length) { hackFires.push({ skill: 'smiteAll', attacker: pending.attacker, targets: ctx2.smiteAllTargets, isHacked: true }); } if (copied.name === 'molt' && ctx2.moltHeal > 0) { hackFires.push({ skill: 'molt', card: pending.attacker, heal: ctx2.moltHeal, armorLost: ctx2.moltArmorLost, isHacked: true }); } if (copied.name === 'berserk') { pending.attacker.currentAttack += hackSkill.value; hackFires.push({ skill: 'berserk', card: pending.attacker, gain: hackSkill.value, isHacked: true }); } if (copied.name === 'flurry') { // Cap hacked flurry at 1 extra attack; inject a temporary skill so _performAttack picks it up pending.attacker.skills.push({ name: 'flurry', trigger: 'passive', value: 1, _hackedSkill: true }); hackFires.push({ skill: 'flurry', card: pending.attacker, isHacked: true }); } } if (hackFires.length > 0) { preAttackFires.push({ skill: 'hack', source: pending.attacker, copiedFrom: pending.target, fires: hackFires }); this._log(`${pending.attacker.name} hack: copied ${hackFires.length} skills from ${pending.target.name}`); } } } // Molt preAttack restore: if attacker has molt and armor was zeroed by molt this // preBattle, restore it to (baseArmor + 1) before the attack so the card fights // with a fresh shell. Condition: molt skill present, armor currently 0, base armor > 0. const moltSkill = pending.attacker.skills.find(s => s.name === 'molt'); if (moltSkill && pending.attacker.currentArmor === 0 && (pending.attacker.baseArmor ?? 0) > 0) { const armorGain = (pending.attacker.baseArmor ?? 0) + 1; pending.attacker.currentArmor = armorGain; preAttackFires.push({ skill: 'moltRestore', card: pending.attacker, armorGain }); this._log(`${pending.attacker.name} molt restore: +${armorGain} ARM before attacking`); } // Emit preAttack event (inserted before any death events from above) const offensiveSkills = pending.attacker.skills.filter(s => s.trigger === 'on_attack' || s.trigger === 'passive' ); this.events.unshift({ type: 'preAttack', attacker: pending.attacker, target: pending.target, side: pending.side, skills: offensiveSkills, preAttackFires }); // If the attacker was killed by a pre-attack skill (e.g. bloodpact self-kill), // skip the attack and any on_attack skills — the card is already dead. if (pending.attacker.currentHP <= 0) { this.events.push({ type: 'postAttack', attacker: null, defender: (pending.target?.currentHP > 0) ? pending.target : null, side: pending.side, alliedLanes: pending.alliedLanes.filter(c => c.currentHP > 0), enemyLanes: pending.enemyLanes.filter(c => c.currentHP > 0), enemyCommander: pending.enemyCommander, pierceRestores: [] }); return [...this.events]; } this._performAttack( pending.attacker, pending.target, pending.enemyCommander, pending.alliedLanes, pending.enemyLanes, pending.side ); // Remove any skills temporarily injected by hack (e.g. hacked flurry) pending.attacker.skills = pending.attacker.skills.filter(s => !s._hackedSkill); // postAttack event — fires after the attack animation resolves. // Only references cards still alive so skill handlers don't need to guard. // pierceRestores: armor that was temporarily reduced by pierce — restore if target survived. // For "all" pierce, restore armor on ALL targets that survived. const pierceRestores = preAttackFires .filter(f => f.skill === 'pierce' && f.amount > 0 && f.target.currentHP > 0) .map(f => ({ card: f.target, amount: f.amount })); // Swarm revert: remove temp buff applied in preAttack so _postBattlePhase doesn't double-remove. let swarmRevert = null; if (pending.attacker._tempBuffs?.length) { const sIdx = pending.attacker._tempBuffs.findIndex(b => b.source === 'swarm'); if (sIdx >= 0) { const swarmBuff = pending.attacker._tempBuffs.splice(sIdx, 1)[0]; pending.attacker.currentAttack = Math.max(0, pending.attacker.currentAttack - swarmBuff.amount); if (pending.attacker.currentHP > 0) { swarmRevert = { card: pending.attacker, amount: swarmBuff.amount }; this._log(`${pending.attacker.name} swarm fades: -${swarmBuff.amount} ATK`); } } } this.events.push({ type: 'postAttack', attacker: pending.attacker.currentHP > 0 ? pending.attacker : null, defender: (pending.target?.currentHP > 0) ? pending.target : null, side: pending.side, alliedLanes: pending.alliedLanes.filter(c => c.currentHP > 0), enemyLanes: pending.enemyLanes.filter(c => c.currentHP > 0), enemyCommander: pending.enemyCommander, pierceRestores, swarmRevert }); return [...this.events]; } hasPendingAttacks() { return !!(this._pendingAttacks && this._pendingAttacks.length > 0); } // Run death-check and win-check after all attacks have been animated. finalizeCommit() { this.events = []; this._deathCheck(); this._winCheck(); this._postBattlePhase(); return [...this.events]; } // Remove all temp buffs/debuffs from a set of cards and return descriptors. // Positive amounts were additions (rally, protect) — subtract to reverse. // Negative amounts were subtractions (enfeeble) — subtract negative = add back. _removeAndCollectDebuffs(cards) { const debuffs = []; for (const card of cards) { if (!card._tempBuffs?.length) continue; for (const buff of card._tempBuffs) { if (buff.stat === 'currentAttack') card.currentAttack = Math.max(0, card.currentAttack - buff.amount); if (buff.stat === 'currentArmor') card.currentArmor = Math.max(0, card.currentArmor - buff.amount); if (buff.stat === 'skillValues') { for (const { index, amount } of buff.amounts) { if (card.skills[index]) card.skills[index].value = (card.skills[index].value || 0) + amount; } } debuffs.push({ skill: buff.source, target: card, amount: buff.amount }); } card._tempBuffs = []; } return debuffs; } // Emit the 8-step post-battle sequence and remove all temp buffs. // playerGoesFirst was already flipped in beginCommit, so !playerGoesFirst = who went first this turn. _postBattlePhase() { const firstWasPlayer = !this.playerGoesFirst; const firstSide = firstWasPlayer ? 'player' : 'opponent'; const otherSide = firstWasPlayer ? 'opponent' : 'player'; const firstCmd = firstWasPlayer ? this.playerCommander : this.opponentCommander; const otherCmd = firstWasPlayer ? this.opponentCommander : this.playerCommander; const firstLanes = firstWasPlayer ? [...this.playerLanes] : [...this.opponentLanes]; const otherLanes = firstWasPlayer ? [...this.opponentLanes] : [...this.playerLanes]; // Steps 1–2: commander defensive debuffs (placeholder) this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: [] }); this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: [] }); // Steps 3–4: commander offensive debuffs const firstCmdDebuffs = this._removeAndCollectDebuffs([firstCmd]); const otherCmdDebuffs = this._removeAndCollectDebuffs([otherCmd]); this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: firstCmdDebuffs }); this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: otherCmdDebuffs }); // Steps 5–6: lane defensive debuffs (placeholder) this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: [] }); this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: [] }); // Steps 7–8: lane offensive debuffs const firstLaneDebuffs = this._removeAndCollectDebuffs(firstLanes); const otherLaneDebuffs = this._removeAndCollectDebuffs(otherLanes); this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: firstLaneDebuffs }); this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: otherLaneDebuffs }); // Decrement burrow stacks now that the full combat round has resolved. this._burrowPhase(); } // Run one full turn, return events (used by runToCompletion) stepTurn() { if (this.winner) return this.events; this.events = []; this.turn++; this._log(`--- Turn ${this.turn} ---`); this._drawPhase(); this._deployPhase(); this._activationPhase(); this._commanderSkillPhase(); this._cardOnTurnStartPhase(); this._rupturePhase(); this._jamPhase(); this._venomPhase(); this._smitePhase(); this._moltPhase(); this._attackPhase(); this._deathCheck(); this._winCheck(); this._burrowPhase(); return [...this.events]; } // Run entire battle to completion (for testing) runToCompletion(maxTurns = 200) { while (!this.winner && this.turn < maxTurns) { this.stepTurn(); } if (!this.winner) { this.winner = 'opponent'; // timeout = player loses this._log('Turn limit reached — Opponent wins'); } return this.winner; } getState() { return { turn: this.turn, winner: this.winner, player: { commander: this.playerCommander, lanes: this.playerLanes, hand: this.playerHand, deckRemaining: this.playerDeck.length - this.playerDeckIdx }, opponent: { commander: this.opponentCommander, lanes: this.opponentLanes, hand: this.ai.hand, deckRemaining: this.ai.deckCards.length - this.ai.deckIndex } }; } }