tyrants-edge/src/combat/CombatEngine.js

1149 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 12: 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 34: 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 56: 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 78: 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 12: 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 34: 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 56: 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 78: 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
}
};
}
}