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