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) { c.currentHP -= c.ruptureStacks; this._log(`${c.name} takes ${c.ruptureStacks} rupture damage`); } } }; applyRupture(this.playerLanes); applyRupture(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() { if (this.playerCommander.currentHP > 0) { for (const s of this.playerCommander.skills) { if (s.trigger === 'on_turn_start') { this.skillProcessor.process(s, this.playerCommander, null, [this.playerCommander, ...this.playerLanes], [this.opponentCommander, ...this.opponentLanes], { enemyCommander: this.opponentCommander, cardManager: this.cardManager } ); } } } if (this.opponentCommander.currentHP > 0) { for (const s of this.opponentCommander.skills) { if (s.trigger === 'on_turn_start') { this.skillProcessor.process(s, this.opponentCommander, null, [this.opponentCommander, ...this.opponentLanes], [this.playerCommander, ...this.playerLanes], { enemyCommander: this.playerCommander, cardManager: this.cardManager } ); } } } } // 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); 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.healAllTargets) { this.events.push({ type: 'healAll', attacker, targets: onAtkCtx.healAllTargets, side }); } if (onAtkCtx.weakenAllTargets) { this.events.push({ type: 'weakenAll', attacker, targets: onAtkCtx.weakenAllTargets, 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 skill on defender — only fires 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 }); } } } } } // Check if defender died if (currentTarget.currentHP <= 0) { this._log(`${currentTarget.name} is destroyed`); this.events.push({ type: 'death', card: currentTarget, side: side === 'player' ? 'opponent' : 'player' }); } } 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' }); } } } } } // 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._rupturePhase(); this._jamPhase(); this._attackPhase(); this._deathCheck(); this._winCheck(); 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._rupturePhase(); this._jamPhase(); this._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 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`); } 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}`); if (!targetIsCommander && ctx.drainTarget.currentHP <= 0) { this._log(`${ctx.drainTarget.name} is destroyed by drain`); this.events.push({ type: 'death', card: ctx.drainTarget, side: this.playerLanes.includes(card) || card === this.playerCommander ? 'opponent' : 'player' }); } 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`); this.events.push({ type: 'death', card: sec.target, side: this.playerLanes.includes(card) || card === this.playerCommander ? 'opponent' : 'player' }); } } } } } return { buffs, siegeFires, protectFires, enfeebeFires, jamFires, drainFires }; } // Emit the 8-step pre-battle sequence and process preBattle skills. _preBattlePhase() { 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: [] }); this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [] }); // Steps 3–4: commander offensive buffs const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]); const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]); this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires, drainFires: firstCmdFires.drainFires }); this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, buffs: otherCmdFires.buffs, siegeFires: otherCmdFires.siegeFires, protectFires: otherCmdFires.protectFires, enfeebeFires: otherCmdFires.enfeebeFires, jamFires: otherCmdFires.jamFires, drainFires: otherCmdFires.drainFires }); // 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: [] }); this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] }); // Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam 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, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires, drainFires: firstLaneFires.drainFires }); this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: otherLaneFires.buffs, siegeFires: otherLaneFires.siegeFires, protectFires: otherLaneFires.protectFires, enfeebeFires: otherLaneFires.enfeebeFires, jamFires: otherLaneFires.jamFires, drainFires: otherLaneFires.drainFires }); } // 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); if (s.name === 'mortar' && ctx.mortarAllTargets) { for (const t of ctx.mortarAllTargets) { preAttackFires.push({ 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: pending.side === 'player' ? 'opponent' : 'player' }); } } } else if (s.name === 'mortar' && ctx.mortarTarget) { // hpBefore: recover the HP value before the skill applied its damage const hpBefore = ctx.mortarTarget.currentHP + ctx.mortarDamage; preAttackFires.push({ 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: pending.side === 'player' ? 'opponent' : 'player' }); } } if (s.name === 'strike' && ctx.strikeAllTargets) { for (const t of ctx.strikeAllTargets) { const targetIsCommander = t.target === pending.enemyCommander; preAttackFires.push({ 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: pending.side === 'player' ? 'opponent' : 'player' }); } } } else if (s.name === 'strike' && ctx.strikeTarget) { const hpBefore = ctx.strikeTarget.currentHP + ctx.strikeDamage; const targetIsCommander = ctx.strikeTarget === pending.enemyCommander; preAttackFires.push({ 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: pending.side === 'player' ? 'opponent' : 'player' }); } } if (s.name === 'swipe' && ctx.swipeAllTargets) { for (const t of ctx.swipeAllTargets) { const targetIsCommander = t.target === pending.enemyCommander; preAttackFires.push({ 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: pending.side === 'player' ? 'opponent' : 'player' }); } } } 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 }); } } } // 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 }); this._performAttack( pending.attacker, pending.target, pending.enemyCommander, pending.alliedLanes, pending.enemyLanes, pending.side ); // 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 })); 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 }); 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 }); } // 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._rupturePhase(); this._jamPhase(); this._attackPhase(); this._deathCheck(); this._winCheck(); 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 } }; } }