647 lines
29 KiB
JavaScript
647 lines
29 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
|
||
_activationPhase() {
|
||
const tick = cards => {
|
||
for (const c of cards) {
|
||
if (c.currentDelay > 0) c.currentDelay--;
|
||
}
|
||
};
|
||
tick(this.playerLanes);
|
||
tick(this.opponentLanes);
|
||
tick(this.playerHand);
|
||
tick(this.ai.hand);
|
||
}
|
||
|
||
// 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, legion — pierce now fires in preAttack phase)
|
||
for (const s of attacker.skills) {
|
||
if (['flurry', 'valor', 'legion'].includes(s.name) &&
|
||
['on_attack', 'passive'].includes(s.trigger)) {
|
||
this.skillProcessor.process(s, attacker, target,
|
||
alliedLanes.filter(c => c.currentHP > 0),
|
||
enemyLanes.filter(c => c.currentHP > 0), context);
|
||
}
|
||
}
|
||
|
||
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') {
|
||
this.skillProcessor.process(s, attacker, currentTarget,
|
||
alliedLanes.filter(c => c.currentHP > 0),
|
||
enemyLanes.filter(c => c.currentHP > 0),
|
||
{ ...context, trigger: 'on_attack', damageDealt: dmg });
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
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 });
|
||
}
|
||
}
|
||
this._activationPhase();
|
||
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 = [];
|
||
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 });
|
||
}
|
||
}
|
||
// 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 });
|
||
}
|
||
}
|
||
this._activationPhase();
|
||
this._commanderSkillPhase();
|
||
this._rupturePhase();
|
||
this._jamPhase();
|
||
this._preBattlePhase();
|
||
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 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;
|
||
for (const s of card.skills) {
|
||
if (s.trigger !== 'preBattle') continue;
|
||
// Pass laneCards + enemyLaneCards so positional skills (protect, enfeeble) work
|
||
const ctx = { rng: this.rng, enemyCommander, laneCards: cards, enemyLaneCards: enemyLanes };
|
||
this.skillProcessor.process(s, card, null, liveAllies, liveEnemies, ctx);
|
||
if (s.name === 'rally' && ctx.rallyTarget) {
|
||
buffs.push({ skill: 'rally', source: card, target: ctx.rallyTarget, amount: s.value });
|
||
}
|
||
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.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('/')}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { buffs, siegeFires, protectFires, enfeebeFires, jamFires };
|
||
}
|
||
|
||
// Emit the 8-step pre-battle sequence and process preBattle skills.
|
||
_preBattlePhase() {
|
||
const firstSide = this.playerGoesFirst ? 'player' : 'opponent';
|
||
const otherSide = this.playerGoesFirst ? 'opponent' : 'player';
|
||
const firstCmd = this.playerGoesFirst ? this.playerCommander : this.opponentCommander;
|
||
const otherCmd = this.playerGoesFirst ? this.opponentCommander : this.playerCommander;
|
||
const firstLanes = this.playerGoesFirst ? [...this.playerLanes] : [...this.opponentLanes];
|
||
const otherLanes = this.playerGoesFirst ? [...this.opponentLanes] : [...this.playerLanes];
|
||
const firstAllies = [firstCmd, ...firstLanes];
|
||
const otherAllies = [otherCmd, ...otherLanes];
|
||
|
||
// Steps 1–2: commander defensive buffs (placeholder — no defensive skills yet)
|
||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, buffs: [], siegeFires: [], protectFires: [] });
|
||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, buffs: [], siegeFires: [], protectFires: [] });
|
||
|
||
// Steps 3–4: commander offensive buffs
|
||
const firstCmdFires = this._collectPreBattleFires([firstCmd], firstAllies, otherAllies, otherCmd, [otherCmd]);
|
||
const otherCmdFires = this._collectPreBattleFires([otherCmd], otherAllies, firstAllies, firstCmd, [firstCmd]);
|
||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, buffs: firstCmdFires.buffs, siegeFires: firstCmdFires.siegeFires, protectFires: firstCmdFires.protectFires, enfeebeFires: firstCmdFires.enfeebeFires, jamFires: firstCmdFires.jamFires });
|
||
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 });
|
||
|
||
// Steps 5–6: lane defensive buffs (placeholder)
|
||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
|
||
this.events.push({ type: 'preBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, buffs: [], siegeFires: [], protectFires: [], enfeebeFires: [], jamFires: [] });
|
||
|
||
// Steps 7–8: lane offensive buffs + siege fires + protect fires + enfeeble fires + jam fires
|
||
const firstLaneFires = this._collectPreBattleFires(firstLanes, firstAllies, otherAllies, otherCmd, otherLanes);
|
||
const otherLaneFires = this._collectPreBattleFires(otherLanes, otherAllies, firstAllies, firstCmd, firstLanes);
|
||
this.events.push({ type: 'preBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, buffs: firstLaneFires.buffs, siegeFires: firstLaneFires.siegeFires, protectFires: firstLaneFires.protectFires, enfeebeFires: firstLaneFires.enfeebeFires, jamFires: firstLaneFires.jamFires });
|
||
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 });
|
||
}
|
||
|
||
// 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 };
|
||
this.skillProcessor.process(s, pending.attacker, pending.target, liveAllies, liveEnemies, ctx);
|
||
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.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 === '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}`);
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
const pierceRestores = preAttackFires
|
||
.filter(f => f.skill === 'pierce' && f.amount > 0 && f.target.currentHP > 0)
|
||
.map(f => ({ card: f.target, amount: f.amount }));
|
||
|
||
this.events.push({
|
||
type: 'postAttack',
|
||
attacker: pending.attacker.currentHP > 0 ? pending.attacker : null,
|
||
defender: (pending.target?.currentHP > 0) ? pending.target : null,
|
||
side: pending.side,
|
||
alliedLanes: pending.alliedLanes.filter(c => c.currentHP > 0),
|
||
enemyLanes: pending.enemyLanes.filter(c => c.currentHP > 0),
|
||
enemyCommander: pending.enemyCommander,
|
||
pierceRestores
|
||
});
|
||
|
||
return [...this.events];
|
||
}
|
||
|
||
hasPendingAttacks() {
|
||
return !!(this._pendingAttacks && this._pendingAttacks.length > 0);
|
||
}
|
||
|
||
// Run death-check and win-check after all attacks have been animated.
|
||
finalizeCommit() {
|
||
this.events = [];
|
||
this._deathCheck();
|
||
this._winCheck();
|
||
this._postBattlePhase();
|
||
return [...this.events];
|
||
}
|
||
|
||
// Remove all temp buffs/debuffs from a set of cards and return descriptors.
|
||
// Positive amounts were additions (rally, protect) — subtract to reverse.
|
||
// Negative amounts were subtractions (enfeeble) — subtract negative = add back.
|
||
_removeAndCollectDebuffs(cards) {
|
||
const debuffs = [];
|
||
for (const card of cards) {
|
||
if (!card._tempBuffs?.length) continue;
|
||
for (const buff of card._tempBuffs) {
|
||
if (buff.stat === 'currentAttack') card.currentAttack = Math.max(0, card.currentAttack - buff.amount);
|
||
if (buff.stat === 'currentArmor') card.currentArmor = Math.max(0, card.currentArmor - buff.amount);
|
||
if (buff.stat === 'skillValues') {
|
||
for (const { index, amount } of buff.amounts) {
|
||
if (card.skills[index]) card.skills[index].value = (card.skills[index].value || 0) + amount;
|
||
}
|
||
}
|
||
debuffs.push({ skill: buff.source, target: card, amount: buff.amount });
|
||
}
|
||
card._tempBuffs = [];
|
||
}
|
||
return debuffs;
|
||
}
|
||
|
||
// Emit the 8-step post-battle sequence and remove all temp buffs.
|
||
// playerGoesFirst was already flipped in beginCommit, so !playerGoesFirst = who went first this turn.
|
||
_postBattlePhase() {
|
||
const firstWasPlayer = !this.playerGoesFirst;
|
||
const firstSide = firstWasPlayer ? 'player' : 'opponent';
|
||
const otherSide = firstWasPlayer ? 'opponent' : 'player';
|
||
const firstCmd = firstWasPlayer ? this.playerCommander : this.opponentCommander;
|
||
const otherCmd = firstWasPlayer ? this.opponentCommander : this.playerCommander;
|
||
const firstLanes = firstWasPlayer ? [...this.playerLanes] : [...this.opponentLanes];
|
||
const otherLanes = firstWasPlayer ? [...this.opponentLanes] : [...this.playerLanes];
|
||
|
||
// Steps 1–2: commander defensive debuffs (placeholder)
|
||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: [] });
|
||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: [] });
|
||
|
||
// Steps 3–4: commander offensive debuffs
|
||
const firstCmdDebuffs = this._removeAndCollectDebuffs([firstCmd]);
|
||
const otherCmdDebuffs = this._removeAndCollectDebuffs([otherCmd]);
|
||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: firstSide, card: firstCmd, debuffs: firstCmdDebuffs });
|
||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'commander', side: otherSide, card: otherCmd, debuffs: otherCmdDebuffs });
|
||
|
||
// Steps 5–6: lane defensive debuffs (placeholder)
|
||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: [] });
|
||
this.events.push({ type: 'postBattle', phase: 'defensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: [] });
|
||
|
||
// Steps 7–8: lane offensive debuffs
|
||
const firstLaneDebuffs = this._removeAndCollectDebuffs(firstLanes);
|
||
const otherLaneDebuffs = this._removeAndCollectDebuffs(otherLanes);
|
||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: firstSide, cards: firstLanes, debuffs: firstLaneDebuffs });
|
||
this.events.push({ type: 'postBattle', phase: 'offensive', target: 'lanes', side: otherSide, cards: otherLanes, debuffs: otherLaneDebuffs });
|
||
}
|
||
|
||
// Run one full turn, return events (used by runToCompletion)
|
||
stepTurn() {
|
||
if (this.winner) return this.events;
|
||
this.events = [];
|
||
this.turn++;
|
||
this._log(`--- Turn ${this.turn} ---`);
|
||
this._drawPhase();
|
||
this._deployPhase();
|
||
this._activationPhase();
|
||
this._commanderSkillPhase();
|
||
this._rupturePhase();
|
||
this._jamPhase();
|
||
this._attackPhase();
|
||
this._deathCheck();
|
||
this._winCheck();
|
||
return [...this.events];
|
||
}
|
||
|
||
// Run entire battle to completion (for testing)
|
||
runToCompletion(maxTurns = 200) {
|
||
while (!this.winner && this.turn < maxTurns) {
|
||
this.stepTurn();
|
||
}
|
||
if (!this.winner) {
|
||
this.winner = 'opponent'; // timeout = player loses
|
||
this._log('Turn limit reached — Opponent wins');
|
||
}
|
||
return this.winner;
|
||
}
|
||
|
||
getState() {
|
||
return {
|
||
turn: this.turn,
|
||
winner: this.winner,
|
||
player: {
|
||
commander: this.playerCommander,
|
||
lanes: this.playerLanes,
|
||
hand: this.playerHand,
|
||
deckRemaining: this.playerDeck.length - this.playerDeckIdx
|
||
},
|
||
opponent: {
|
||
commander: this.opponentCommander,
|
||
lanes: this.opponentLanes,
|
||
hand: this.ai.hand,
|
||
deckRemaining: this.ai.deckCards.length - this.ai.deckIndex
|
||
}
|
||
};
|
||
}
|
||
}
|