fertig-classic-games/src/games/advancewars/AdvanceWarsLogic.js

960 lines
36 KiB
JavaScript

// Advance Wars headless engine. No Phaser imports; runs in Node.
// All rules simulation lives here: map decode, movement, combat, capture,
// production, economy, fuel, transports, fog, CO powers, objectives.
// The scene, the AI and tools/verifyAdvanceWars.js all drive the game
// through applyAction(); every action returns an event list the view can
// replay as animation.
import { baseDamage } from './AdvanceWarsRules.js';
export const SAVE_VERSION = 1;
// ---------------------------------------------------------------------------
// Seeded PRNG (mulberry32) — state.rngState mutates on every draw so games
// are reproducible from a seed.
export function rngNext(state) {
state.rngState = (state.rngState + 0x6d2b79f5) >>> 0;
let t = state.rngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function rngInt(state, maxInclusive) {
return Math.floor(rngNext(state) * (maxInclusive + 1));
}
// ---------------------------------------------------------------------------
// Construction
export function decodeMap(rules, mapDef) {
const { w, h, tiles } = mapDef;
if (!Array.isArray(tiles) || tiles.length !== h) throw new Error('map: tile rows != h');
const terrain = new Array(w * h);
for (let y = 0; y < h; y++) {
const row = tiles[y];
if (row.length !== w) throw new Error(`map: row ${y} width ${row.length} != ${w}`);
for (let x = 0; x < w; x++) {
const t = rules.terrainByCh[row[x]];
if (!t) throw new Error(`map: unknown terrain char '${row[x]}' at ${x},${y}`);
terrain[y * w + x] = t.index;
}
}
return terrain;
}
export function createGame(rules, mapDef, opts = {}) {
const terrain = decodeMap(rules, mapDef);
const { w, h } = mapDef;
const cos = opts.cos ?? ['andy', 'olaf'];
const nArmies = cos.length;
const funds = opts.startFunds ?? new Array(nArmies).fill(0);
const state = {
v: SAVE_VERSION,
day: 1,
turn: 0,
fog: !!opts.fog,
production: opts.production !== false,
unlockedUnits: opts.unlockedUnits,
rngState: (opts.seed ?? 1) >>> 0,
w, h,
terrain,
owner: new Array(w * h).fill(-1),
captureHp: new Array(w * h).fill(rules.constants.captureGoal),
armies: cos.map((co, i) => {
if (!rules.coById[co]) throw new Error(`unknown CO ${co}`);
return {
co, funds: funds[i] ?? 0, charge: 0, powerActive: false, alive: true,
hadUnits: false, team: opts.teams?.[i] ?? (i === 0 ? 0 : 1),
};
}),
units: [],
nextUnitId: 1,
objective: opts.objective ?? { type: 'rout' },
dayLimit: opts.dayLimit ?? rules.constants.dayLimitDefault,
result: null,
};
for (const p of mapDef.properties ?? []) {
const t = rules.terrains[terrain[p.y * w + p.x]];
if (!t?.property) throw new Error(`map: property owner at ${p.x},${p.y} but terrain is ${t?.id}`);
if (p.owner < 0 || p.owner >= nArmies) throw new Error(`map: property owner ${p.owner} out of range`);
state.owner[p.y * w + p.x] = p.owner;
}
for (const u of mapDef.units ?? []) {
spawnUnit(rules, state, u);
}
state.armies.forEach((a, i) => { a.hadUnits = state.units.some((u) => u.army === i); });
// Army 0 opens the game with a fresh day (income etc.) exactly like every
// later turn start.
const events = [];
beginTurn(rules, state, events);
state._openingEvents = events;
return state;
}
function spawnUnit(rules, state, def) {
const spec = rules.unitById[def.type];
if (!spec) throw new Error(`unknown unit type ${def.type}`);
if (def.army < 0 || def.army >= state.armies.length) throw new Error(`unit army ${def.army} out of range`);
const t = rules.terrains[state.terrain[def.y * state.w + def.x]];
if (t.cost[spec.moveType] == null) throw new Error(`unit ${def.type} on impassable ${t.id} at ${def.x},${def.y}`);
if (unitAt(state, def.x, def.y)) throw new Error(`two units at ${def.x},${def.y}`);
const unit = {
id: state.nextUnitId++,
army: def.army,
type: def.type,
x: def.x, y: def.y,
hp: def.hp ?? 100,
fuel: def.fuel ?? spec.fuel,
ammo: def.ammo ?? spec.ammo,
moved: false,
capturing: false,
dived: false,
cargo: (def.cargo ?? []).map((c) => ({
id: state.nextUnitId++,
army: def.army,
type: c.type,
x: def.x, y: def.y,
hp: c.hp ?? 100,
fuel: c.fuel ?? rules.unitById[c.type].fuel,
ammo: c.ammo ?? rules.unitById[c.type].ammo,
moved: false, capturing: false, dived: false, cargo: [],
})),
};
state.units.push(unit);
return unit;
}
// ---------------------------------------------------------------------------
// Lookups & CO effects
export function tileKey(state, x, y) { return y * state.w + x; }
export function inBounds(state, x, y) { return x >= 0 && y >= 0 && x < state.w && y < state.h; }
export function terrainAt(rules, state, x, y) { return rules.terrains[state.terrain[y * state.w + x]]; }
export function unitAt(state, x, y) {
return state.units.find((u) => u.x === x && u.y === y) ?? null;
}
// Two armies are hostile when they're on different teams (campaign finale
// fields two allied enemy armies).
export function hostile(state, armyA, armyB) {
return state.armies[armyA].team !== state.armies[armyB].team;
}
export function unitById(state, id) {
for (const u of state.units) {
if (u.id === id) return u;
const c = u.cargo.find((k) => k.id === id);
if (c) return c;
}
return null;
}
export function hpDisplay(unit) { return Math.ceil(unit.hp / 10); }
// Merged day-to-day + active-power effects for an army. Power keys override
// d2d keys while the power is active.
export function coEffects(rules, state, army) {
const co = rules.coById[state.armies[army].co];
const d2d = co.d2d ?? {};
if (!state.armies[army].powerActive || !co.power) return d2d;
return { ...d2d, ...co.power.effects };
}
function effectFor(rules, state, army) { return coEffects(rules, state, army); }
// Attack % for a unit given its army's CO effects. Modifiers are additive
// around the 100 baseline, matching how AW stacks category bonuses.
export function attackPct(rules, state, unit) {
const fx = effectFor(rules, state, unit.army);
const spec = rules.unitById[unit.type];
let pct = fx.atk ?? 100;
const foot = spec.moveType === 'foot' || spec.moveType === 'boots';
if (spec.indirect) {
if (fx.indirectAtk != null) pct += fx.indirectAtk - 100;
} else if (spec.range) {
if (fx.directAtk != null) pct += fx.directAtk - 100;
if (!foot && fx.nonFootDirectAtk != null) pct += fx.nonFootDirectAtk - 100;
}
if (foot && fx.footAtk != null) pct += fx.footAtk - 100;
if (spec.domain === 'air' && fx.airAtk != null) pct += fx.airAtk - 100;
if (spec.domain === 'sea' && fx.seaAtk != null) pct += fx.seaAtk - 100;
return pct;
}
export function defensePct(rules, state, unit) {
const fx = effectFor(rules, state, unit.army);
return fx.def ?? 100;
}
export function effectiveRange(rules, state, unit) {
const spec = rules.unitById[unit.type];
if (!spec.range) return null;
if (!spec.indirect) return spec.range;
const fx = effectFor(rules, state, unit.army);
const mod = fx.rangeMod ?? 0;
return [spec.range[0], Math.max(spec.range[0], spec.range[1] + mod)];
}
export function effectiveMove(rules, state, unit) {
const spec = rules.unitById[unit.type];
const fx = effectFor(rules, state, unit.army);
let move = spec.move;
const foot = spec.moveType === 'foot' || spec.moveType === 'boots';
if (foot && fx.footMove) move += fx.footMove;
if (!foot && !spec.indirect && spec.range && fx.directMove) move += fx.directMove;
if (spec.domain === 'sea' && fx.seaMove) move += fx.seaMove;
if (spec.transport && fx.transportMove) move += fx.transportMove;
return Math.min(move, unit.fuel);
}
// Terrain cost for one army's unit, honoring Sturm's flat costs and any
// enemy Blizzard (snow) that is currently active.
export function moveCost(rules, state, unit, tIdx) {
const t = rules.terrains[tIdx];
const spec = rules.unitById[unit.type];
let cost = t.cost[spec.moveType];
if (cost == null) return null;
const fx = effectFor(rules, state, unit.army);
if (fx.terrainCostFlat) cost = 1;
const snowed = state.armies.some((a, i) =>
i !== unit.army && a.alive && a.powerActive &&
(rules.coById[a.co].power?.effects?.snow));
if (snowed) cost += 1;
return cost;
}
// ---------------------------------------------------------------------------
// Movement — Dijkstra flood from the unit's tile. Friendly tiles can be
// passed through but not stopped on (except load/join, validated at apply
// time). Enemy units block; in fog invisible enemies don't block here but
// trap the unit during execution.
export function reachableTiles(rules, state, unit) {
const move = effectiveMove(rules, state, unit);
const start = tileKey(state, unit.x, unit.y);
const dist = new Map([[start, 0]]);
const prev = new Map();
const visible = state.fog ? computeVision(rules, state, unit.army) : null;
// occupancy snapshot: units don't move mid-flood, and this turns the O(units)
// per-neighbor scan into O(1) — the AI calls this constantly on big armies
const occAt = new Map();
for (const u of state.units) occAt.set(u.y * state.w + u.x, u);
// per-terrain-index cost cache (CO/snow modifiers are constant for the flood)
const costCache = new Map();
const costOf = (tIdx) => {
if (!costCache.has(tIdx)) costCache.set(tIdx, moveCost(rules, state, unit, tIdx));
return costCache.get(tIdx);
};
const frontier = [[0, start]];
while (frontier.length) {
let bi = 0;
for (let i = 1; i < frontier.length; i++) if (frontier[i][0] < frontier[bi][0]) bi = i;
const [d, key] = frontier.splice(bi, 1)[0];
if (d > (dist.get(key) ?? Infinity)) continue;
const x = key % state.w, y = Math.floor(key / state.w);
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inBounds(state, nx, ny)) continue;
const nk = ny * state.w + nx;
const cost = costOf(state.terrain[nk]);
if (cost == null) continue;
const occ = occAt.get(nk);
if (occ && occ.army !== unit.army) {
if (!hostile(state, occ.army, unit.army)) continue; // allies always block
// visible enemies block pathing; invisible ones are handled as traps
if (!state.fog || isUnitSpotted(rules, state, occ, unit.army, visible)) continue;
}
const nd = d + cost;
if (nd > move) continue;
if (nd < (dist.get(nk) ?? Infinity)) {
dist.set(nk, nd);
prev.set(nk, key);
frontier.push([nd, nk]);
}
}
}
return { dist, prev, start };
}
export function pathFromReach(state, reach, destKey) {
if (!reach.dist.has(destKey)) return null;
const path = [];
let k = destKey;
while (k !== undefined && k !== reach.start) {
path.unshift({ x: k % state.w, y: Math.floor(k / state.w) });
k = reach.prev.get(k);
}
return path;
}
// Tiles a unit may END its move on (unoccupied, or its own tile).
export function canStopAt(state, unit, x, y) {
const occ = unitAt(state, x, y);
return !occ || occ === unit;
}
// ---------------------------------------------------------------------------
// Vision / fog
export function computeVision(rules, state, army) {
const vis = new Set();
const fx = effectFor(rules, state, army);
const bonus = fx.visionMod ?? 0;
const add = (x, y, range) => {
for (let dy = -range; dy <= range; dy++) {
for (let dx = -(range - Math.abs(dy)); dx <= range - Math.abs(dy); dx++) {
const nx = x + dx, ny = y + dy;
if (inBounds(state, nx, ny)) vis.add(tileKey(state, nx, ny));
}
}
};
for (const u of state.units) {
if (u.army !== army) continue;
const spec = rules.unitById[u.type];
let range = spec.vision + bonus;
const t = terrainAt(rules, state, u.x, u.y);
if (t.visionBonus && (spec.moveType === 'foot' || spec.moveType === 'boots')) range += t.visionBonus;
add(u.x, u.y, range);
}
for (let k = 0; k < state.owner.length; k++) {
if (state.owner[k] === army) vis.add(k);
}
return vis;
}
// A unit is spotted by `army` when its tile is visible AND it is not hidden
// (wood/reef hider, dived sub) — hidden units need an adjacent friendly.
export function isUnitSpotted(rules, state, unit, army, vision = null) {
if (unit.army === army) return true;
if (!state.fog) return !unit.dived || adjacentToArmy(state, unit, army);
const vis = vision ?? computeVision(rules, state, army);
if (!vis.has(tileKey(state, unit.x, unit.y))) return false;
const t = terrainAt(rules, state, unit.x, unit.y);
if (unit.dived || t.hidesInFog) return adjacentToArmy(state, unit, army);
return true;
}
function adjacentToArmy(state, unit, army) {
return state.units.some((u) => u.army === army &&
Math.abs(u.x - unit.x) + Math.abs(u.y - unit.y) === 1);
}
export function visibleUnits(rules, state, army) {
if (!state.fog) return state.units.filter((u) => u.army === army || !u.dived || adjacentToArmy(state, u, army));
const vis = computeVision(rules, state, army);
return state.units.filter((u) => isUnitSpotted(rules, state, u, army, vis));
}
// ---------------------------------------------------------------------------
// Combat
// The classic AW formula. luck is an explicit roll so previews pass 0 and
// resolveAttack draws from the seeded rng.
export function computeDamage(rules, state, attacker, defender, luck = 0) {
const base = baseDamage(rules, attacker, defender.type);
if (!base) return null;
const atk = attackPct(rules, state, attacker);
const def = defensePct(rules, state, defender);
const stars = defender.dived ? 0 : terrainAt(rules, state, defender.x, defender.y).stars;
// air units never get terrain cover
const spec = rules.unitById[defender.type];
const effStars = spec.domain === 'air' ? 0 : stars;
const dmg = Math.floor(
(base.dmg * atk / 100 + luck) *
(hpDisplay(attacker) / 10) *
((200 - def - effStars * hpDisplay(defender)) / 100)
);
return { dmg: Math.max(0, dmg), weapon: base.weapon };
}
export function canAttack(rules, state, attacker, defender, { afterMove = false } = {}) {
if (!hostile(state, attacker.army, defender.army)) return false;
const spec = rules.unitById[attacker.type];
if (!spec.range) return false;
if (spec.indirect && afterMove) return false;
const range = effectiveRange(rules, state, attacker);
const d = Math.abs(attacker.x - defender.x) + Math.abs(attacker.y - defender.y);
if (d < range[0] || d > range[1]) return false;
if (defender.dived && !['cruiser', 'sub'].includes(attacker.type)) return false;
return baseDamage(rules, attacker, defender.type) != null;
}
function spendAmmo(rules, unit, weapon) {
const spec = rules.unitById[unit.type];
if (weapon === 'primary' && spec.ammo > 0) unit.ammo = Math.max(0, unit.ammo - 1);
}
function fundsValue(rules, unitLike, hpAmount) {
return rules.unitById[unitLike.type].cost * (hpAmount / 100);
}
function addCharge(rules, state, army, amount) {
const a = state.armies[army];
const co = rules.coById[a.co];
if (!co.power || a.powerActive) return;
const cap = co.power.stars * rules.constants.starCharge;
a.charge = Math.min(cap, a.charge + Math.floor(amount));
}
// One volley from `from` into `to`. Mutates hp, spends ammo, accrues charge.
function volley(rules, state, from, to, events) {
const luck = rngInt(state, effectFor(rules, state, from.army).luckMax ?? rules.constants.luckMax);
const res = computeDamage(rules, state, from, to, luck);
if (!res) return;
const dealt = Math.min(to.hp, res.dmg);
to.hp -= dealt;
spendAmmo(rules, from, res.weapon);
addCharge(rules, state, from.army, fundsValue(rules, to, dealt) * rules.constants.chargeDealtMult);
addCharge(rules, state, to.army, fundsValue(rules, to, dealt) * rules.constants.chargeTakenMult);
events.push({
type: 'battle', attackerId: from.id, defenderId: to.id,
attacker: { type: from.type, army: from.army, x: from.x, y: from.y, hp: from.hp },
defender: { type: to.type, army: to.army, x: to.x, y: to.y, hp: to.hp },
dmg: dealt, killed: to.hp <= 0,
});
}
function resolveAttack(rules, state, attacker, defender, events) {
const defFx = effectFor(rules, state, defender.army);
const defenderCountersFirst = !!defFx.counterFirst &&
canAttack(rules, state, defender, attacker, { afterMove: false }) &&
!rules.unitById[defender.type].indirect;
const order = defenderCountersFirst ? [[defender, attacker], [attacker, defender]]
: [[attacker, defender], [defender, attacker]];
for (const [from, to] of order) {
if (from.hp <= 0 || to.hp <= 0) continue;
// the "counter" half only happens for adjacent direct defenders
if (from === defender) {
const dspec = rules.unitById[defender.type];
if (dspec.indirect) continue;
if (Math.abs(from.x - to.x) + Math.abs(from.y - to.y) !== 1) continue;
if (!dspec.range) continue;
if (baseDamage(rules, from, to.type) == null) continue;
}
volley(rules, state, from, to, events);
}
removeDead(rules, state, events);
}
function removeDead(rules, state, events) {
for (const u of [...state.units]) {
if (u.hp <= 0) {
if (u.capturing) state.captureHp[tileKey(state, u.x, u.y)] = rules.constants.captureGoal;
events.push({ type: 'destroyed', unitId: u.id, x: u.x, y: u.y, unitType: u.type, army: u.army });
state.units.splice(state.units.indexOf(u), 1);
}
}
}
// ---------------------------------------------------------------------------
// Turn / day cycle
function beginTurn(rules, state, events) {
const army = state.turn;
const a = state.armies[army];
const co = rules.coById[a.co];
// own power expires at the start of our next turn
a.powerActive = false;
events.push({ type: 'dayStart', day: state.day, army });
// income
let income = 0;
for (let k = 0; k < state.owner.length; k++) {
if (state.owner[k] === army) income += rules.constants.incomePerProperty;
}
a.funds += income;
if (income) events.push({ type: 'income', army, amount: income });
// repairs + resupply on friendly properties
for (const u of state.units) {
if (u.army !== army) continue;
const t = terrainAt(rules, state, u.x, u.y);
const spec = rules.unitById[u.type];
if (state.owner[tileKey(state, u.x, u.y)] === army && t.repairs?.includes(spec.domain)) {
if (u.hp < 100) {
const want = Math.min(rules.constants.repairPerTurn, 100 - u.hp);
const costPerPoint = spec.cost / 100;
const affordable = Math.min(want, Math.floor(a.funds / costPerPoint));
if (affordable > 0) {
u.hp += affordable;
a.funds -= Math.round(affordable * costPerPoint);
events.push({ type: 'repair', unitId: u.id, amount: affordable });
}
}
u.fuel = spec.fuel;
u.ammo = spec.ammo;
}
}
// APC auto-resupply
for (const u of state.units) {
if (u.army !== army || !rules.unitById[u.type].supplies) continue;
supplyAdjacent(rules, state, u, events);
}
// daily fuel + crashes
const fx = coEffects(rules, state, army);
for (const u of [...state.units]) {
if (u.army !== army) continue;
const spec = rules.unitById[u.type];
let burn = u.dived ? (spec.dive?.dailyFuel ?? spec.dailyFuel ?? 0) : (spec.dailyFuel ?? 0);
if (spec.domain === 'air' && fx.airFuelSave) burn = Math.max(1, burn - fx.airFuelSave);
if (burn > 0) {
u.fuel -= burn;
if (u.fuel < 0 && (spec.domain === 'air' || spec.domain === 'sea')) {
u.hp = 0;
events.push({ type: 'crashed', unitId: u.id, x: u.x, y: u.y, unitType: u.type, army });
} else if (u.fuel < 0) {
u.fuel = 0;
}
}
}
removeDead(rules, state, events);
// fresh orders
for (const u of state.units) {
if (u.army === army) u.moved = false;
}
if (state.units.some((u) => u.army === army)) a.hadUnits = true;
checkResult(rules, state, events);
}
function nextAliveArmy(state, from) {
let i = from;
for (let n = 0; n < state.armies.length; n++) {
i = (i + 1) % state.armies.length;
if (state.armies[i].alive) return i;
}
return from;
}
// ---------------------------------------------------------------------------
// Result / elimination
function eliminateArmy(rules, state, army, events, { neutralize = true } = {}) {
const a = state.armies[army];
if (!a.alive) return;
a.alive = false;
for (const u of [...state.units]) {
if (u.army === army) state.units.splice(state.units.indexOf(u), 1);
}
if (neutralize) {
for (let k = 0; k < state.owner.length; k++) {
if (state.owner[k] === army) {
state.owner[k] = -1;
state.captureHp[k] = rules.constants.captureGoal;
}
}
}
events.push({ type: 'armyEliminated', army });
}
export function checkResult(rules, state, events = []) {
if (state.result) return state.result;
// rout eliminations
state.armies.forEach((a, i) => {
if (!a.alive || !a.hadUnits) return;
const hasUnits = state.units.some((u) => u.army === i);
if (!hasUnits) {
const canRebuild = state.production && a.funds >= 1000 &&
state.owner.some((o, k) => o === i && rules.terrains[state.terrain[k]].builds);
if (!canRebuild) eliminateArmy(rules, state, i, events);
}
});
const playerAlive = state.armies.some((a) => a.alive && a.team === 0);
const enemiesAlive = state.armies.some((a) => a.alive && a.team !== 0);
if (!playerAlive) {
state.result = { winner: 'enemy', reason: 'rout' };
} else if (!enemiesAlive) {
state.result = { winner: 'player', reason: 'rout' };
} else if (state.objective.type === 'capturecount') {
const owned = state.owner.filter((o) => o === 0).length;
if (owned >= state.objective.n) state.result = { winner: 'player', reason: 'capturecount' };
} else if (state.objective.type === 'survive') {
if (state.day > state.objective.days) state.result = { winner: 'player', reason: 'survived' };
}
if (!state.result && state.day > state.dayLimit) {
state.result = { winner: 'enemy', reason: 'daylimit' };
}
if (state.result) events.push({ type: 'gameOver', ...state.result });
return state.result;
}
// ---------------------------------------------------------------------------
// Actions
function fail(error) { return { ok: false, error, events: [] }; }
function executeMove(rules, state, unit, path, events, { allowOccupiedEnd = false } = {}) {
// Validates the whole walk first, then commits. Returns { trapped } —
// trapped means an invisible hostile blocked the way; the unit stops just
// before it and its turn ends there.
if (!path || path.length === 0) return { trapped: false, cost: 0 };
let px = unit.x, py = unit.y;
let total = 0, spent = 0;
const move = effectiveMove(rules, state, unit);
let endX = unit.x, endY = unit.y;
let trapped = false;
for (const step of path) {
if (Math.abs(step.x - px) + Math.abs(step.y - py) !== 1) return { error: 'bad path step' };
const cost = moveCost(rules, state, unit, state.terrain[tileKey(state, step.x, step.y)]);
if (cost == null) return { error: 'impassable' };
total += cost;
if (total > move) return { error: 'path too long' };
const occ = unitAt(state, step.x, step.y);
if (occ && occ !== unit && occ.army !== unit.army) {
if (hostile(state, occ.army, unit.army)) { trapped = true; break; }
return { error: 'blocked' };
}
px = step.x; py = step.y;
if (!occ || occ === unit) { endX = step.x; endY = step.y; spent = total; }
}
if (!trapped && (px !== endX || py !== endY)) return { error: 'cannot stop on occupied tile' };
if (!trapped && !allowOccupiedEnd && !canStopAt(state, unit, endX, endY)) {
return { error: 'cannot stop on occupied tile' };
}
const from = { x: unit.x, y: unit.y };
if (unit.capturing && (endX !== unit.x || endY !== unit.y)) {
// walking off a half-captured property resets its meter
state.captureHp[tileKey(state, unit.x, unit.y)] = rules.constants.captureGoal;
}
unit.fuel = Math.max(0, unit.fuel - spent);
unit.x = endX; unit.y = endY;
unit.capturing = false;
events.push({ type: 'moved', unitId: unit.id, from, to: { x: endX, y: endY }, path, trapped });
return { trapped };
}
function requireActiveUnit(state, action) {
const unit = unitById(state, action.unitId);
if (!unit) return { error: 'no such unit' };
if (unit.army !== state.turn) return { error: 'not your unit' };
if (unit.moved) return { error: 'already moved' };
if (state.units.indexOf(unit) < 0) return { error: 'unit is loaded' };
return { unit };
}
export function applyAction(state, rules, action) {
if (state.result) return fail('game over');
const events = [];
const army = state.turn;
switch (action.type) {
case 'endTurn': {
for (const u of state.units) if (u.army === army) u.moved = true;
const wasLast = nextAliveArmy(state, army) <= army;
state.turn = nextAliveArmy(state, army);
if (wasLast) state.day += 1;
beginTurn(rules, state, events);
return { ok: true, events };
}
case 'power': {
const a = state.armies[army];
const co = rules.coById[a.co];
if (!co.power) return fail('no power');
const cap = co.power.stars * rules.constants.starCharge;
if (a.charge < cap) return fail('power not charged');
a.charge = 0;
a.powerActive = true;
events.push({ type: 'powerFired', army, co: a.co, power: co.power.name });
applyImmediatePower(rules, state, army, co.power.effects, events);
removeDead(rules, state, events);
checkResult(rules, state, events);
return { ok: true, events };
}
case 'build': {
const { x, y, unitType } = action;
const t = terrainAt(rules, state, x, y);
const spec = rules.unitById[unitType];
if (!spec) return fail('unknown unit type');
if (!t.builds || state.owner[tileKey(state, x, y)] !== army) return fail('not your factory');
const domain = spec.domain === 'air' ? 'air' : spec.domain === 'sea' ? 'sea' : 'land';
if (t.builds !== domain) return fail('wrong factory type');
const allowed = state.unlockedUnits?.[army];
if (allowed && !allowed.includes(unitType)) return fail('unit not unlocked');
if (unitAt(state, x, y)) return fail('tile occupied');
const fx = coEffects(rules, state, army);
const cost = Math.round(spec.cost * (fx.costMult ?? 1));
if (state.armies[army].funds < cost) return fail('insufficient funds');
state.armies[army].funds -= cost;
const unit = spawnUnit(rules, state, { army, type: unitType, x, y });
unit.moved = true;
state.armies[army].hadUnits = true;
events.push({ type: 'built', unitId: unit.id, unitType, x, y, army, cost });
return { ok: true, events };
}
}
// everything below acts on a specific unit
const got = requireActiveUnit(state, action);
if (got.error) return fail(got.error);
const unit = got.unit;
const spec = rules.unitById[unit.type];
const moveRes = executeMove(rules, state, unit, action.path ?? [], events);
if (moveRes.error) return fail(moveRes.error);
const movedTiles = (action.path ?? []).length > 0;
if (moveRes.trapped) {
unit.moved = true;
checkResult(rules, state, events);
return { ok: true, events, trapped: true };
}
switch (action.type) {
case 'wait':
break;
case 'attack': {
const target = unitById(state, action.targetId);
if (!target) { rollbackIllegal(state, unit); return fail('no target'); }
if (!canAttack(rules, state, unit, target, { afterMove: movedTiles })) {
return fail('cannot attack');
}
resolveAttack(rules, state, unit, target, events);
break;
}
case 'capture': {
if (!spec.capture) return fail('cannot capture');
const k = tileKey(state, unit.x, unit.y);
const t = rules.terrains[state.terrain[k]];
if (!t.property || state.owner[k] === army) return fail('nothing to capture');
if (state.owner[k] >= 0 && !hostile(state, state.owner[k], army)) return fail('allied property');
const fx = coEffects(rules, state, army);
const points = Math.floor(hpDisplay(unit) * (fx.captureMult ?? 1));
const before = state.captureHp[k];
state.captureHp[k] = Math.max(0, state.captureHp[k] - points);
unit.capturing = state.captureHp[k] > 0;
events.push({ type: 'capturing', unitId: unit.id, x: unit.x, y: unit.y, before, left: state.captureHp[k] });
if (state.captureHp[k] === 0) {
const prevOwner = state.owner[k];
state.owner[k] = army;
state.captureHp[k] = rules.constants.captureGoal;
events.push({ type: 'captured', unitId: unit.id, x: unit.x, y: unit.y, army, prevOwner });
if (t.hq && prevOwner >= 0) {
eliminateArmy(rules, state, prevOwner, events);
if (prevOwner === 0) state.result = { winner: 'enemy', reason: 'hq' };
else if (!state.armies.some((a, i) => i > 0 && a.alive)) {
state.result = { winner: 'player', reason: 'hq' };
}
if (state.result) events.push({ type: 'gameOver', ...state.result });
}
}
break;
}
case 'load': {
// path must end on the transport's tile — executeMove refuses to stop
// on occupied tiles, so 'load' paths end adjacent and we hop on here.
const transport = unitAt(state, action.x, action.y);
if (!transport || transport.army !== army) return fail('no transport');
const tspec = rules.unitById[transport.type];
if (!tspec.transport) return fail('not a transport');
if (Math.abs(unit.x - action.x) + Math.abs(unit.y - action.y) !== 1 &&
!(unit.x === action.x && unit.y === action.y)) return fail('not adjacent to transport');
if (transport.cargo.length >= tspec.transport.cap) return fail('transport full');
const carries = tspec.transport.carries;
const ok = carries === 'landUnits'
? rules.unitById[unit.type].domain === 'land'
: carries.includes(unit.type);
if (!ok) return fail('cannot carry that unit');
state.units.splice(state.units.indexOf(unit), 1);
transport.cargo.push(unit);
unit.x = transport.x; unit.y = transport.y;
events.push({ type: 'loaded', unitId: unit.id, transportId: transport.id });
break;
}
case 'unload': {
if (!spec.transport) return fail('not a transport');
if (!unit.cargo.length) return fail('empty');
for (const drop of action.drops ?? []) {
const cargo = unit.cargo[drop.cargoIndex];
if (!cargo) return fail('bad cargo index');
if (Math.abs(drop.x - unit.x) + Math.abs(drop.y - unit.y) !== 1) return fail('drop not adjacent');
const t = terrainAt(rules, state, drop.x, drop.y);
if (t.cost[rules.unitById[cargo.type].moveType] == null) return fail('cargo cannot stand there');
if (unitAt(state, drop.x, drop.y)) return fail('drop tile occupied');
unit.cargo.splice(drop.cargoIndex, 1);
cargo.x = drop.x; cargo.y = drop.y;
cargo.moved = true;
state.units.push(cargo);
events.push({ type: 'unloaded', unitId: cargo.id, transportId: unit.id, x: drop.x, y: drop.y });
}
break;
}
case 'supply': {
if (!spec.supplies) return fail('cannot supply');
supplyAdjacent(rules, state, unit, events);
break;
}
case 'dive': {
if (!spec.dive) return fail('cannot dive');
unit.dived = true;
events.push({ type: 'dived', unitId: unit.id });
break;
}
case 'rise': {
if (!unit.dived) return fail('not dived');
unit.dived = false;
events.push({ type: 'rose', unitId: unit.id });
break;
}
case 'join': {
const other = unitAt(state, action.x, action.y);
if (!other || other === unit) return fail('nothing to join');
if (other.army !== army || other.type !== unit.type) return fail('join needs same type');
if (other.moved && other.hp >= 100) return fail('cannot join');
if (Math.abs(unit.x - action.x) + Math.abs(unit.y - action.y) !== 1) return fail('not adjacent');
other.hp = Math.min(100, other.hp + unit.hp);
other.fuel = Math.min(rules.unitById[other.type].fuel, other.fuel + unit.fuel);
other.ammo = Math.min(rules.unitById[other.type].ammo, other.ammo + unit.ammo);
state.units.splice(state.units.indexOf(unit), 1);
events.push({ type: 'joined', unitId: unit.id, intoId: other.id });
break;
}
default:
return fail(`unknown action ${action.type}`);
}
unit.moved = true;
checkResult(rules, state, events);
return { ok: true, events };
}
function rollbackIllegal() { /* moves are already committed; callers validate targets via canAttack first */ }
function supplyAdjacent(rules, state, supplier, events) {
for (const u of state.units) {
if (u.army !== supplier.army || u === supplier) continue;
if (Math.abs(u.x - supplier.x) + Math.abs(u.y - supplier.y) !== 1) continue;
const spec = rules.unitById[u.type];
if (u.fuel < spec.fuel || u.ammo < spec.ammo) {
u.fuel = spec.fuel;
u.ammo = spec.ammo;
events.push({ type: 'supplied', unitId: u.id, byId: supplier.id });
}
}
}
function applyImmediatePower(rules, state, army, effects, events) {
if (effects.healAll) {
for (const u of state.units) {
if (u.army === army) u.hp = Math.min(100, u.hp + effects.healAll * 10);
}
events.push({ type: 'healedAll', army, amount: effects.healAll });
}
if (effects.refreshNonInfantry) {
for (const u of state.units) {
if (u.army === army && !['infantry', 'mech'].includes(u.type)) u.moved = false;
}
events.push({ type: 'refreshed', army });
}
if (effects.globalDamage) {
for (const u of state.units) {
if (hostile(state, u.army, army)) {
u.hp = Math.max(1, u.hp - effects.globalDamage * 10);
}
}
events.push({ type: 'tsunami', army, amount: effects.globalDamage });
}
if (effects.meteor) {
const { dmg, radius } = effects.meteor;
let best = null;
for (let y = 0; y < state.h; y++) {
for (let x = 0; x < state.w; x++) {
let value = 0;
for (const u of state.units) {
if (Math.abs(u.x - x) + Math.abs(u.y - y) > radius) continue;
const worth = fundsValue(rules, u, u.hp);
value += hostile(state, u.army, army) ? worth : -worth;
}
if (!best || value > best.value) best = { x, y, value };
}
}
if (best) {
for (const u of state.units) {
if (Math.abs(u.x - best.x) + Math.abs(u.y - best.y) <= radius && hostile(state, u.army, army)) {
u.hp = Math.max(1, u.hp - dmg * 10);
}
}
events.push({ type: 'meteor', army, x: best.x, y: best.y, radius, dmg });
}
}
}
// ---------------------------------------------------------------------------
// Convenience queries used by scene + AI
export function attackTargetsFrom(rules, state, unit, x, y, { afterMove }) {
// Enemies attackable if the unit stood at (x, y). Uses a shallow proxy so
// the caller can probe candidate destinations without mutating state.
const probe = { ...unit, x, y };
const list = [];
for (const enemy of state.units) {
if (!hostile(state, enemy.army, unit.army)) continue;
if (state.fog && !isUnitSpotted(rules, state, enemy, unit.army)) continue;
if (canAttack(rules, state, probe, enemy, { afterMove })) list.push(enemy);
}
return list;
}
export function buildOptions(rules, state, x, y) {
const t = terrainAt(rules, state, x, y);
const army = state.turn;
if (!t.builds || state.owner[tileKey(state, x, y)] !== army || unitAt(state, x, y)) return [];
const fx = coEffects(rules, state, army);
const allowed = state.unlockedUnits?.[army];
return rules.units
.filter((u) => (u.domain === 'air' ? 'air' : u.domain === 'sea' ? 'sea' : 'land') === t.builds)
.filter((u) => !allowed || allowed.includes(u.id))
.map((u) => ({
type: u.id,
cost: Math.round(u.cost * (fx.costMult ?? 1)),
affordable: state.armies[army].funds >= Math.round(u.cost * (fx.costMult ?? 1)),
}));
}
export function powerReady(rules, state, army) {
const a = state.armies[army];
const co = rules.coById[a.co];
if (!co.power || a.powerActive) return false;
return a.charge >= co.power.stars * rules.constants.starCharge;
}
// ---------------------------------------------------------------------------
// Serialization
export function serialize(state) {
const { _openingEvents, ...rest } = state;
return JSON.stringify(rest);
}
export function deserialize(json) {
const state = JSON.parse(json);
if (state.v !== SAVE_VERSION) throw new Error(`save version ${state.v} != ${SAVE_VERSION}`);
return state;
}