tyrants-edge/src/combat/CombatEngine.js

647 lines
29 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
_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 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 });
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 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 });
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 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
}
};
}
}