tyrants-edge/src/combat/CombatEngine.js

765 lines
36 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) {
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 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: [] });
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [] });
// Steps 34: 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 56: 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 78: 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 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 });
}
// 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
}
};
}
}