feat: add Advance Wars turn-based tactics game

Implement a full Advance Wars clone with campaign (20 missions) and
War Room skirmish mode. Includes:

- Headless game engine (AdvanceWarsLogic) with complete AW1 rules:
  terrain movement, combat, capture, production, transports, fuel,
  fog of war, CO powers, and multiple objective types
- AI opponent (skill 1-5, aggression/capture tuning) with BFS
  pathfinding, threat evaluation, production counter-tables, and
  fog trap awareness
- Canvas-rendered map view with procedural stand-ins that can be
  replaced by drop-in painted spritesheets (no code changes)
- 11 playable COs with unique day-to-day modifiers and powers
  (Hyper Repair, Lightning Strike, Tsunami, Meteor Strike, etc.)
- Campaign briefings with typewriter text and opponent portraits
- Comprehensive headless verification suite (rules, combat formula,
  pathfinding, economy, capture, fog, CO powers, soak tests)
- New assets: terrain/building sheets, adventure music tracks,
  game icon frame 86
- Refactor SpireClimb and SWDBG to use the shared soundtrack service
This commit is contained in:
Brian Fertig 2026-07-19 09:43:58 -06:00
parent 7713089466
commit 4a8b78c538
38 changed files with 9161 additions and 14 deletions

View File

@ -27,13 +27,13 @@
"w": 85
},
{
"x": 2224,
"y": 3048,
"x": 2201,
"y": 2859,
"w": 72
},
{
"x": 1916,
"y": 2797,
"x": 1911,
"y": 2848,
"w": 87
},
{
@ -67,18 +67,18 @@
"w": 72
},
{
"x": 1531,
"y": 1152,
"x": 1381,
"y": 1119,
"w": 82
},
{
"x": 1950,
"y": 1494,
"x": 1885,
"y": 1264,
"w": 72
},
{
"x": 2198,
"y": 1195,
"x": 2329,
"y": 1146,
"w": 85
},
{
@ -226,6 +226,16 @@
"sprite": "tower",
"x": 2890,
"y": 2935
},
{
"sprite": "neon",
"x": 2179,
"y": 2731
},
{
"sprite": "lamppost",
"x": 2217,
"y": 2998
}
],
"coins": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,27 @@
{
"_comment": "Drop-in painted art for Advance Wars, one sheet per category (spec: src/games/advancewars/sprites.md). Paint a sheet, put it at the suggested path under assets/images/advancewars/, set its path below and reload — no code changes needed. Any sheet left path:null renders procedurally. Rivers and roads are drawn in code and need no art.",
"terrainSheet": {
"key": "advancewars-terrain",
"path": "assets/images/advancewars/advancewars-terrain.png",
"frameWidth": 48,
"frameHeight": 48
},
"buildingSheet": {
"key": "advancewars-buildings",
"path": "assets/images/advancewars/advancewars-buildings.png",
"frameWidth": 48,
"frameHeight": 64
},
"unitSheet": {
"key": "advancewars-units",
"path": null,
"frameWidth": 48,
"frameHeight": 64
},
"uiSheet": {
"key": "advancewars-ui",
"path": null,
"frameWidth": 48,
"frameHeight": 48
}
}

File diff suppressed because it is too large Load Diff

1104
data/advancewars-rules.json Normal file

File diff suppressed because it is too large Load Diff

35
data/adventure-music.json Normal file
View File

@ -0,0 +1,35 @@
{
"tracks": [
{
"file": "adventure-track01.mp3",
"artist": "Aiko",
"title": "The Asteroid Field"
},
{
"file": "adventure-track02.mp3",
"artist": "Mario",
"title": "Enter the Labyrinth"
},
{
"file": "adventure-track03.mp3",
"artist": "Balam",
"title": "Amongst the Ruins"
},
{
"file": "adventure-track04.mp3",
"artist": "Zanthor",
"title": "Times Past"
},
{
"file": "adventure-track05.mp3",
"artist": "Nadia",
"title": "A time after now, but also before"
},
{
"file": "adventure-track06.mp3",
"artist": "Kage",
"title": "In the Bamboo Trees"
}
],
"volume": 0.5
}

View File

@ -9,7 +9,7 @@
"citySheets is keyed by theme. Adding e.g. an `asian` entry makes it selectable with no code change."
],
"terrainSheet": { "key": "civilization-terrain", "path": "assets/images/civilization/civilization-terrain.png", "frameWidth": 128, "frameHeight": 96 },
"resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 },
"resourceSheet": { "key": "civilization-resources", "path": "assets/images/civilization/civilization-resources.png", "frameWidth": 64, "frameHeight": 64 },
"improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 },
"unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 },
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },

View File

@ -199,6 +199,17 @@ export const MANIFEST = {
// its audio only downloads once Super Kart is actually entered.
(scene) => musicFrom(scene, 'nintendo-music'),
],
// Rules + campaign always load; the painted sheet is an optional drop-in
// declared in data/advancewars-artwork.json (spec: src/games/advancewars/
// sprites.md) — path:null stays procedural. Shares Super Kart's nintendo
// soundtrack (see services/soundtrack.js).
advancewars: [
{ type: 'json', key: 'advancewars-rules', path: 'data/advancewars-rules.json' },
{ type: 'json', key: 'advancewars-campaign', path: 'data/advancewars-campaign.json' },
(scene) => sheetsFrom(scene, 'advancewars-artwork',
['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet']),
(scene) => musicFrom(scene, 'nintendo-music'),
],
coloradodefense: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Colorado Defense is actually entered.

View File

@ -113,3 +113,4 @@ registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-cons
registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 83 });
registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 84 });
registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 85 });
registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 86 });

View File

@ -0,0 +1,492 @@
// Advance Wars CPU commander. Headless (no Phaser); runs in Node.
// runAITurn(rules, state, army, profile) plays out the whole turn by calling
// Logic.applyAction directly and returns [{ action, events }, ...] so the
// scene can replay the events with animation delays.
//
// profile: { skill: 1-5, aggression: 0-1, captureWeight: 0-1 }
// skill gates how much the AI "thinks": counters, threat avoidance,
// production counter-tables all switch on at higher skills.
import * as Logic from './AdvanceWarsLogic.js';
const AVG_LUCK = 4;
export function runAITurn(rules, state, army, profile = {}) {
const skill = Math.max(1, Math.min(5, profile.skill ?? 3));
const aggression = profile.aggression ?? 0.5;
const captureWeight = profile.captureWeight ?? 0.4;
const log = [];
const act = (action) => {
const res = Logic.applyAction(state, rules, action);
if (res.ok) log.push({ action, events: res.events });
return res;
};
if (state.turn !== army || state.result) return log;
// material ratio drives desperation/breakthrough: a clearly stronger army
// accepts worse trades so sieges actually end instead of stalling out
const myValue = state.units.reduce((s, u) => s + (u.army === army ? unitValue(rules, u) : 0), 0);
const enemyValue = state.units.reduce((s, u) =>
s + (Logic.hostile(state, u.army, army) ? unitValue(rules, u) : 0), 0);
const surplus = Math.max(0, Math.min(2, myValue / Math.max(1, enemyValue) - 1));
// ── CO power ──────────────────────────────────────────────────────────────
if (Logic.powerReady(rules, state, army)) {
const eager = skill <= 2 || anyContact(rules, state, army);
if (eager) act({ type: 'power' });
}
if (state.result) return log;
const myUnits = () => state.units.filter((u) => u.army === army && !u.moved);
// ── Indirects: fire without moving, else reposition ───────────────────────
for (const unit of myUnits()) {
const spec = rules.unitById[unit.type];
if (!spec.indirect) continue;
const targets = Logic.attackTargetsFrom(rules, state, unit, unit.x, unit.y, { afterMove: false });
if (targets.length) {
const best = pickBestTarget(rules, state, unit, targets, skill);
if (best) { act({ type: 'attack', unitId: unit.id, path: [], targetId: best.id }); continue; }
}
// reposition toward the front, keeping min-range clearance
const goal = nearestEnemy(rules, state, unit);
if (goal) {
const range = Logic.effectiveRange(rules, state, unit);
const dest = advanceDestination(rules, state, unit, goal, { minDist: range ? range[0] : 0, skill });
if (dest) act({ type: 'wait', unitId: unit.id, path: dest.path });
}
if (state.result) return log;
}
// ── Capturers ─────────────────────────────────────────────────────────────
const claimed = new Set();
const hqThreat = findThreatenedHQ(rules, state, army);
let hqDefenderSent = state.units.some((u) => u.army === army &&
hqThreat && u.x === hqThreat.x && u.y === hqThreat.y);
for (const unit of myUnits()) {
const spec = rules.unitById[unit.type];
if (!spec.capture) continue;
const here = Logic.tileKey(state, unit.x, unit.y);
const hereT = rules.terrains[state.terrain[here]];
// garrison: don't walk off a threatened HQ; fight or hold from it
if (hqThreat && unit.x === hqThreat.x && unit.y === hqThreat.y) {
const targets = Logic.attackTargetsFrom(rules, state, unit, unit.x, unit.y, { afterMove: false });
const best = targets.length ? pickBestTarget(rules, state, unit, targets, skill) : null;
if (best) act({ type: 'attack', unitId: unit.id, path: [], targetId: best.id });
else act({ type: 'wait', unitId: unit.id, path: [] });
continue;
}
// divert the nearest foot unit home when the HQ is exposed
if (hqThreat && !hqDefenderSent && skill >= 2) {
const reach = Logic.reachableTiles(rules, state, unit);
const hqKey = Logic.tileKey(state, hqThreat.x, hqThreat.y);
hqDefenderSent = true;
if (reach.dist.has(hqKey) && Logic.canStopAt(state, unit, hqThreat.x, hqThreat.y)) {
act({ type: 'wait', unitId: unit.id, path: Logic.pathFromReach(state, reach, hqKey) });
continue;
}
const dest = advanceDestination(rules, state, unit, hqThreat, { skill });
if (dest && dest.path.length) { act({ type: 'wait', unitId: unit.id, path: dest.path }); continue; }
}
// keep finishing a capture in progress
if (unit.capturing && hereT.property && capturable(rules, state, here, army)) {
act({ type: 'capture', unitId: unit.id, path: [] });
continue;
}
if (hereT.property && capturable(rules, state, here, army)) {
act({ type: 'capture', unitId: unit.id, path: [] });
claimed.add(here);
continue;
}
if (Logic.rngNext(state) < captureWeight || skill >= 2) {
const target = nearestProperty(rules, state, unit, claimed, army);
if (target) {
claimed.add(target.key);
const reach = Logic.reachableTiles(rules, state, unit);
if (reach.dist.has(target.key) && Logic.canStopAt(state, unit, target.x, target.y)) {
const path = Logic.pathFromReach(state, reach, target.key);
const res = act({ type: 'capture', unitId: unit.id, path });
if (res.ok) continue;
}
const dest = advanceDestination(rules, state, unit, target, { skill });
if (dest) { act({ type: 'wait', unitId: unit.id, path: dest.path }); continue; }
}
}
if (state.result) return log;
}
// ── Subs: dive when threatened ────────────────────────────────────────────
for (const unit of myUnits()) {
const spec = rules.unitById[unit.type];
if (spec.dive && !unit.dived && skill >= 3 && enemyNear(rules, state, unit, 6)) {
// dive happens with the attack/move below if possible; a lone dive
// wastes the turn, so only dive when nothing is in range
const targets = Logic.attackTargetsFrom(rules, state, unit, unit.x, unit.y, { afterMove: true });
if (!targets.length) act({ type: 'dive', unitId: unit.id, path: [] });
}
}
// ── Direct combat units ───────────────────────────────────────────────────
for (const unit of myUnits()) {
const spec = rules.unitById[unit.type];
if (!spec.range || spec.indirect) continue;
const plan = bestAttackPlan(rules, state, unit, { skill, aggression, surplus });
if (plan) {
act({ type: 'attack', unitId: unit.id, path: plan.path, targetId: plan.target.id });
} else {
const goal = nearestEnemy(rules, state, unit) ?? nearestProperty(rules, state, unit, claimed, army);
let movedOut = false;
if (goal) {
const dest = advanceDestination(rules, state, unit, goal, { skill });
if (dest && dest.path.length) { act({ type: 'wait', unitId: unit.id, path: dest.path }); movedOut = true; }
}
// stuck on one of our factories with nowhere better to go: step aside
if (!movedOut) {
const hereT = Logic.terrainAt(rules, state, unit.x, unit.y);
const hereK = Logic.tileKey(state, unit.x, unit.y);
if (hereT.builds && state.owner[hereK] === army) {
const reach = Logic.reachableTiles(rules, state, unit);
for (const [dx, dy] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
const nx = unit.x + dx, ny = unit.y + dy;
const nk = Logic.tileKey(state, nx, ny);
if (Logic.inBounds(state, nx, ny) && reach.dist.has(nk) &&
Logic.canStopAt(state, unit, nx, ny)) {
act({ type: 'wait', unitId: unit.id, path: Logic.pathFromReach(state, reach, nk) });
break;
}
}
}
}
}
if (state.result) return log;
}
// ── Transports ────────────────────────────────────────────────────────────
for (const unit of myUnits()) {
const spec = rules.unitById[unit.type];
if (!spec.transport) continue;
if (unit.cargo.length) {
// head toward the nearest capturable property and unload beside it
const goal = nearestProperty(rules, state, unit, new Set(), army);
if (goal) {
const dest = advanceDestination(rules, state, unit, goal, { skill });
if (dest && dest.path.length) act({ type: 'wait', unitId: unit.id, path: dest.path });
const drops = [];
const fresh = Logic.unitById(state, unit.id);
if (fresh && !fresh.moved) { /* move failed; try unloading in place */ }
const u2 = Logic.unitById(state, unit.id);
for (let ci = u2.cargo.length - 1; ci >= 0; ci--) {
const cargo = u2.cargo[ci];
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = u2.x + dx, ny = u2.y + dy;
if (!Logic.inBounds(state, nx, ny)) continue;
const t = Logic.terrainAt(rules, state, nx, ny);
if (t.cost[rules.unitById[cargo.type].moveType] == null) continue;
if (Logic.unitAt(state, nx, ny)) continue;
if (drops.some((d) => d.x === nx && d.y === ny)) continue;
drops.push({ cargoIndex: ci, x: nx, y: ny });
break;
}
}
if (drops.length && !u2.moved) act({ type: 'unload', unitId: u2.id, path: [], drops });
else if (drops.length && u2.moved) { /* already acted this turn */ }
}
} else if (spec.supplies) {
// APC: sidle up to the thirstiest stack
const thirsty = state.units.filter((u) => u.army === army && u !== unit &&
(u.fuel < rules.unitById[u.type].fuel * 0.4 ||
(rules.unitById[u.type].ammo > 0 && u.ammo <= 1)));
if (thirsty.length) {
const goal = thirsty[0];
const dest = advanceDestination(rules, state, unit, goal, { skill });
if (dest && dest.path.length) act({ type: 'supply', unitId: unit.id, path: dest.path });
}
}
if (state.result) return log;
}
// ── Production ────────────────────────────────────────────────────────────
if (state.production) {
produce(rules, state, army, { skill, act });
}
if (!state.result && state.turn === army) act({ type: 'endTurn' });
return log;
}
// ---------------------------------------------------------------------------
function capturable(rules, state, key, army) {
const owner = state.owner[key];
return owner !== army && (owner < 0 || Logic.hostile(state, owner, army));
}
// Our HQ tile when a hostile capture-capable unit is close enough to matter.
function findThreatenedHQ(rules, state, army) {
for (let k = 0; k < state.terrain.length; k++) {
if (!rules.terrains[state.terrain[k]].hq || state.owner[k] !== army) continue;
const x = k % state.w, y = Math.floor(k / state.w);
const threat = state.units.some((u) => Logic.hostile(state, u.army, army) &&
rules.unitById[u.type].capture &&
Math.abs(u.x - x) + Math.abs(u.y - y) <= 6);
if (threat) return { x, y, key: k };
}
return null;
}
// Bonus for shooting units that are stealing our stuff — a capturer on one of
// our properties (worst of all: our HQ) must die first.
function defensePriority(rules, state, target, army) {
const k = Logic.tileKey(state, target.x, target.y);
const t = rules.terrains[state.terrain[k]];
if (!t.property || state.owner[k] !== army) return 0;
let bonus = rules.unitById[target.type].capture ? 500 : 200;
if (target.capturing) bonus += 500;
if (t.hq) bonus += 1500;
return bonus;
}
function anyContact(rules, state, army) {
return state.units.some((u) => u.army === army &&
state.units.some((e) => Logic.hostile(state, e.army, army) &&
Math.abs(e.x - u.x) + Math.abs(e.y - u.y) <= 4));
}
function enemyNear(rules, state, unit, dist) {
return state.units.some((e) => Logic.hostile(state, e.army, unit.army) &&
Math.abs(e.x - unit.x) + Math.abs(e.y - unit.y) <= dist);
}
function unitValue(rules, u) {
return rules.unitById[u.type].cost * (u.hp / 100);
}
function expectedDamageValue(rules, state, attacker, defender) {
const res = Logic.computeDamage(rules, state, attacker, defender, AVG_LUCK);
if (!res) return 0;
const dealt = Math.min(defender.hp, res.dmg);
return rules.unitById[defender.type].cost * (dealt / 100);
}
function pickBestTarget(rules, state, unit, targets, skill) {
let best = null, bestScore = -Infinity;
for (const t of targets) {
let score = expectedDamageValue(rules, state, unit, t);
// higher skill prefers finishing kills and hitting threats to indirects
const res = Logic.computeDamage(rules, state, unit, t, AVG_LUCK);
if (skill >= 3 && res && res.dmg >= t.hp) score *= 1.4;
if (skill >= 4 && !rules.unitById[t.type].indirect && rules.unitById[t.type].range) score *= 1.1;
score += defensePriority(rules, state, t, unit.army);
if (score > bestScore) { bestScore = score; best = t; }
}
return bestScore > 0 ? best : null;
}
// Best move+attack pair for a direct unit. Considers every reachable stop
// tile and every adjacent enemy from there.
function bestAttackPlan(rules, state, unit, { skill, aggression, surplus = 0 }) {
const reach = Logic.reachableTiles(rules, state, unit);
let best = null, bestScore = -Infinity;
for (const key of reach.dist.keys()) {
const x = key % state.w, y = Math.floor(key / state.w);
if (!Logic.canStopAt(state, unit, x, y)) continue;
const afterMove = key !== reach.start;
const targets = Logic.attackTargetsFrom(rules, state, unit, x, y, { afterMove });
for (const t of targets) {
const probe = { ...unit, x, y };
const dealtValue = expectedDamageValue(rules, state, probe, t);
if (dealtValue <= 0) continue;
let counterValue = 0;
if (skill >= 2) {
const res = Logic.computeDamage(rules, state, probe, t, AVG_LUCK);
const defenderLeft = Math.max(0, t.hp - (res?.dmg ?? 0));
if (defenderLeft > 0 && !rules.unitById[t.type].indirect && rules.unitById[t.type].range &&
Math.abs(t.x - x) + Math.abs(t.y - y) === 1) {
const counterProbe = { ...t, hp: defenderLeft };
const cres = Logic.computeDamage(rules, state, counterProbe, probe, AVG_LUCK);
counterValue = cres ? rules.unitById[unit.type].cost * (Math.min(unit.hp, cres.dmg) / 100) : 0;
}
}
const stars = Logic.terrainAt(rules, state, x, y).stars;
let score = dealtValue - counterValue * (skill >= 3 ? 0.8 : 0.4) + stars * 40;
const res2 = Logic.computeDamage(rules, state, { ...unit, x, y }, t, AVG_LUCK);
if (skill >= 3 && res2 && res2.dmg >= t.hp) score += 300;
score += defensePriority(rules, state, t, unit.army);
if (score > bestScore) {
bestScore = score;
best = { key, x, y, target: t };
}
}
}
// aggressive COs take rougher trades; a big material surplus turns sieges
// into pushes instead of day-limit stalemates
const threshold = 250 - aggression * 400 - surplus * 600;
if (!best || bestScore < threshold) return null;
return { path: Logic.pathFromReach(state, reach, best.key) ?? [], target: best.target };
}
function nearestEnemy(rules, state, unit) {
let best = null, bd = Infinity;
for (const e of state.units) {
if (!Logic.hostile(state, e.army, unit.army)) continue;
if (state.fog && !Logic.isUnitSpotted(rules, state, e, unit.army)) continue;
// ignore enemies this unit can never hurt (transports chasing subs etc.)
const d = Math.abs(e.x - unit.x) + Math.abs(e.y - unit.y);
if (d < bd) { bd = d; best = e; }
}
return best;
}
function nearestProperty(rules, state, unit, claimed, army) {
let best = null, bd = Infinity;
for (let k = 0; k < state.terrain.length; k++) {
const t = rules.terrains[state.terrain[k]];
if (!t.property || claimed.has(k)) continue;
if (!capturable(rules, state, k, army)) continue;
const x = k % state.w, y = Math.floor(k / state.w);
// land capturers only (fly-in handled via transports elsewhere)
if (t.cost[rules.unitById[unit.type].moveType] == null &&
!rules.unitById[unit.type].transport) continue;
const d = Math.abs(x - unit.x) + Math.abs(y - unit.y);
if (d < bd) { bd = d; best = { x, y, key: k, hq: t.hq }; }
}
return best;
}
// True walking distance from every tile to the goal for this unit's move
// type, ignoring units. Greedy manhattan advance strands armies on the
// shoreline across from the enemy — this BFS routes them over the bridges.
function goalDistanceField(rules, state, unit, goal) {
const spec = rules.unitById[unit.type];
const field = new Map();
const startKey = goal.y * state.w + goal.x;
field.set(startKey, 0);
const queue = [[startKey, 0]];
while (queue.length) {
const [key, d] = queue.shift();
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 (!Logic.inBounds(state, nx, ny)) continue;
const nk = ny * state.w + nx;
if (field.has(nk)) continue;
const t = rules.terrains[state.terrain[nk]];
if (t.cost[spec.moveType] == null) continue;
field.set(nk, d + 1);
queue.push([nk, d + 1]);
}
}
return field;
}
// Reachable stop tile that gets the unit closest to goal. minDist keeps
// indirects out of point-blank range. Skill 4+ avoids tiles the enemy can
// punish hard (cheap threat check: adjacent enemy direct units).
function advanceDestination(rules, state, unit, goal, { minDist = 0, skill = 3 } = {}) {
const reach = Logic.reachableTiles(rules, state, unit);
const field = goalDistanceField(rules, state, unit, goal);
let best = null, bestScore = Infinity;
for (const key of reach.dist.keys()) {
const x = key % state.w, y = Math.floor(key / state.w);
if (!Logic.canStopAt(state, unit, x, y)) continue;
const d = field.get(key) ?? (Math.abs(x - goal.x) + Math.abs(y - goal.y)) + state.w;
if (d < minDist) continue;
let score = d * 100 + reach.dist.get(key);
if (skill >= 4) {
const heat = state.units.reduce((sum, e) => {
if (!Logic.hostile(state, e.army, unit.army)) return sum;
const ed = Math.abs(e.x - x) + Math.abs(e.y - y);
return sum + (ed <= 1 ? 60 : 0);
}, 0);
score += heat;
}
score -= Logic.terrainAt(rules, state, x, y).stars * 5;
// don't squat on our own factories — it strangles production
const tHere = Logic.terrainAt(rules, state, x, y);
if (tHere.builds && state.owner[key] === unit.army) score += 350;
if (score < bestScore) { bestScore = score; best = key; }
}
if (best == null || best === reach.start) return null;
return { key: best, path: Logic.pathFromReach(state, reach, best) ?? [] };
}
// ---------------------------------------------------------------------------
// Production
function produce(rules, state, army, { skill, act }) {
// soft unit cap (AW1 capped at 50): stop building when the map is getting
// saturated — endless production just gridlocks the chokepoints
const cap = Math.max(16, Math.min(40, Math.floor((state.w * state.h) / 6)));
if (state.units.filter((u) => u.army === army).length >= cap) return;
const factories = [];
for (let k = 0; k < state.terrain.length; k++) {
const t = rules.terrains[state.terrain[k]];
if (t.builds && state.owner[k] === army && !Logic.unitAt(state, k % state.w, Math.floor(k / state.w))) {
factories.push({ k, x: k % state.w, y: Math.floor(k / state.w), builds: t.builds });
}
}
// land bases first (bread and butter), then air, then sea
factories.sort((a, b) => 'land air sea'.indexOf(a.builds) - 'land air sea'.indexOf(b.builds));
const enemies = state.units.filter((u) => Logic.hostile(state, u.army, army));
const own = state.units.filter((u) => u.army === army);
const capturable = state.owner.reduce((n, o, k) =>
n + (rules.terrains[state.terrain[k]].property && o !== army ? 1 : 0), 0);
for (const f of factories) {
const options = Logic.buildOptions(rules, state, f.x, f.y).filter((o) => o.affordable);
if (!options.length) continue;
let pick = null;
const infCount = state.units.filter((u) => u.army === army && rules.unitById[u.type].capture).length;
const wantInf = infCount < Math.min(4, Math.max(2, Math.floor(capturable / 2)));
if (f.builds === 'land' && wantInf && options.some((o) => o.type === 'infantry')) {
pick = 'infantry';
} else if (skill <= 1) {
pick = options[Math.floor(Logic.rngNext(state) * options.length)].type;
} else if (skill <= 3) {
const template = f.builds === 'land'
? ['tank', 'artillery', 'antiair', 'tank', 'mech', 'rockets', 'mdtank']
: f.builds === 'air'
? ['bcopter', 'fighter', 'bomber']
: ['cruiser', 'sub', 'battleship'];
const enemyAirValue = enemies.reduce((s, e) =>
s + (rules.unitById[e.type].domain === 'air' ? unitValue(rules, e) : 0), 0);
const list = enemyAirValue > 15000 && f.builds === 'land'
? ['antiair', ...template] : template;
pick = list.find((t) => options.some((o) => o.type === t));
} else {
// counter-table: maximize expected damage value against the enemy mix.
// With a fat treasury, stop discounting price — buy the heavy metal.
// Force-mix caps keep the army mobile: an all-artillery or all-mech
// army never actually crosses the map.
const rich = state.armies[army].funds > 30000;
const mine = state.units.filter((u) => u.army === army);
const indirects = mine.filter((u) => rules.unitById[u.type].indirect).length;
const foot = mine.filter((u) => ['foot', 'boots'].includes(rules.unitById[u.type].moveType)).length;
const tooManyIndirect = indirects >= Math.max(2, mine.length * 0.3);
const tooManyFoot = foot >= Math.max(3, mine.length * 0.45);
let bestScore = -Infinity;
for (const o of options) {
const spec = rules.unitById[o.type];
if (!spec.range) continue; // transports scored separately below
if (spec.indirect && tooManyIndirect) continue;
if (['foot', 'boots'].includes(spec.moveType) && tooManyFoot) continue;
let score = 0;
for (const e of enemies) {
const dmg = rules.damage[o.type]?.[e.type] ?? rules.damageSecondary[o.type]?.[e.type] ?? 0;
score += (dmg / 100) * unitValue(rules, e);
}
if (!rich) score /= Math.max(1, Math.sqrt(o.cost / 1000));
if (score > bestScore) { bestScore = score; pick = o.type; }
}
if (!pick) pick = options[0].type;
}
if (pick && options.some((o) => o.type === pick)) {
act({ type: 'build', x: f.x, y: f.y, unitType: pick });
}
}
}

View File

@ -0,0 +1,118 @@
// Advance Wars battle cut-in: the classic side-vs-side clash panel, built
// from the same 2-frame map unit art scaled up. Driven purely by engine
// battle events; tap to skip. Toggleable from the pause menu.
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import * as Logic from './AdvanceWarsLogic.js';
import { armyColorInt } from './AdvanceWarsMapView.js';
import { FONT } from './AdvanceWarsUI.js';
const W = 760;
const H = 380;
// battles: array of engine 'battle' events (1 or 2 volleys).
// Shows attacker left, defender right; each volley flashes and drains HP.
// texKey is the unit sheet (painted or procedural).
export function playBattleAnim(scene, rules, texKey, battles, onDone) {
if (!battles.length) { onDone?.(); return null; }
const cx = GAME_WIDTH / 2 - 130, cy = GAME_HEIGHT / 2 - 40;
const objs = [];
let finished = false;
const timers = [];
const first = battles[0];
const leftSide = first.attacker;
const rightSide = first.defender;
const dim = scene.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.35)
.setDepth(50).setInteractive();
const panel = scene.add.rectangle(cx, cy, W, H, 0x10141f, 0.97)
.setStrokeStyle(3, 0x3a4260).setDepth(50);
const split = scene.add.rectangle(cx, cy, 4, H, 0x3a4260, 1).setDepth(50);
objs.push(dim, panel, split);
const ground = (side, color) => scene.add
.rectangle(cx + side * W / 4, cy + H / 2 - 40, W / 2 - 8, 80, color, 1).setDepth(50);
objs.push(ground(-1, 0x27324a), ground(1, 0x27324a));
const mkFighter = (side, info) => {
const spec = rules.unitById[info.type];
const img = scene.add.image(cx + side * W / 4, cy + H / 2 - 78, texKey, spec.frame)
.setScale(3.4).setOrigin(0.5, 1).setDepth(51)
.setFlipX(side > 0)
.setTint(armyColorInt(rules, info.army));
objs.push(img);
const label = scene.add.text(cx + side * W / 4, cy - H / 2 + 26,
spec.name, { fontFamily: FONT, fontSize: '26px', color: '#ffffff' })
.setOrigin(0.5, 0.5).setDepth(51);
objs.push(label);
const barBg = scene.add.rectangle(cx + side * W / 4, cy - H / 2 + 58, 220, 18, 0x0a0c14, 1)
.setStrokeStyle(2, 0x3a4260).setDepth(51);
const bar = scene.add.rectangle(cx + side * W / 4 - 108, cy - H / 2 + 58, 216, 12,
0x7dff9a, 1).setOrigin(0, 0.5).setDepth(52);
objs.push(barBg, bar);
return { img, bar, spec };
};
// starting HP = hp before the volleys (event carries post-hit hp; rebuild)
let leftHp = first.attacker.hp;
let rightHp = first.defender.hp;
for (const b of battles) {
if (b.attackerId === first.attackerId) rightHp = b.defender.hp + b.dmg;
else leftHp = b.defender.hp + b.dmg;
}
const left = mkFighter(-1, leftSide);
const right = mkFighter(1, rightSide);
const setBar = (f, hp) => {
f.bar.width = Math.max(0, 216 * (hp / 100));
f.bar.setFillStyle(hp > 50 ? 0x7dff9a : hp > 25 ? 0xffe14d : 0xff5a5a, 1);
};
setBar(left, leftHp);
setBar(right, rightHp);
const flip = scene.time.addEvent({
delay: 220, loop: true, callback: () => {
for (const f of [left, right]) {
const base = f.spec.frame;
f.img.setFrame(f.img.frame.name === base ? base + 1 : base);
}
},
});
timers.push(flip);
const cleanup = () => {
if (finished) return;
finished = true;
for (const t of timers) t.remove();
for (const o of objs) o.destroy();
onDone?.();
};
dim.on('pointerdown', cleanup);
let delay = 350;
for (const b of battles) {
const fromLeft = b.attackerId === first.attackerId;
const shooter = fromLeft ? left : right;
const victim = fromLeft ? right : left;
timers.push(scene.time.delayedCall(delay, () => {
if (finished) return;
scene.tweens.add({
targets: shooter.img, x: shooter.img.x + (fromLeft ? 40 : -40),
duration: 90, yoyo: true,
});
const flash = scene.add.rectangle(victim.img.x, victim.img.y - 60, W / 2 - 20, H - 60, 0xffffff, 0.55).setDepth(53);
objs.push(flash);
scene.tweens.add({ targets: flash, alpha: 0, duration: 240, onComplete: () => flash.destroy() });
scene.cameras.main.shake(120, 0.004);
if (fromLeft) { rightHp = b.defender.hp; setBar(right, rightHp); }
else { leftHp = b.defender.hp; setBar(left, leftHp); }
if (b.killed) {
scene.tweens.add({ targets: victim.img, alpha: 0, y: victim.img.y + 16, duration: 320 });
}
}));
delay += 620;
}
timers.push(scene.time.delayedCall(delay + 350, cleanup));
return { skip: cleanup };
}

View File

@ -0,0 +1,616 @@
// Advance Wars — faithful AW1-style turn-based tactics. Campaign + War Room.
// The scene is a thin coordinator: all rules live in AdvanceWarsLogic (pure,
// Node-testable), the CPU in AdvanceWarsAI, rendering in AdvanceWarsMapView,
// HUD/menus in AdvanceWarsUI, full-screen flows in AdvanceWarsScreens.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { api } from '../../services/api.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import { compileRules } from './AdvanceWarsRules.js';
import * as Logic from './AdvanceWarsLogic.js';
import { runAITurn } from './AdvanceWarsAI.js';
import { AdvanceWarsMapView, ensureSheets, armyColorInt } from './AdvanceWarsMapView.js';
import { AdvanceWarsHUD, ActionMenu, DamagePreview, ProductionMenu, TileInfo, mkButton, mkText } from './AdvanceWarsUI.js';
import * as Screens from './AdvanceWarsScreens.js';
import { playBattleAnim } from './AdvanceWarsBattleAnim.js';
const SAVE_KEY = 'advancewars-save';
const ANIM_KEY = 'advancewars-battle-anims';
const OBJECTIVE_TEXT = {
rout: 'Destroy all enemy units!',
hq: 'Capture the enemy HQ (or rout them)!',
capturecount: 'Capture properties!',
survive: 'Survive!',
};
export default class AdvanceWarsGame extends Phaser.Scene {
constructor() { super('AdvanceWarsGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'advancewars', name: 'Advance Wars' };
this.rules = null;
this.campaign = null;
this.oppById = {};
this.screen = null; // current full-screen flow controller
this.run = null; // active mission runtime
this.levelsCompleted = 0;
this.battleAnims = localStorage.getItem(ANIM_KEY) !== '0';
}
async create() {
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
else {
const fallback = this.cache.json.get('music')?.tracks ?? [];
if (fallback.length) this.music = new MusicPlayer(this, fallback);
}
} catch (_) { /* optional */ }
this.input.mouse?.disableContextMenu();
this.rules = compileRules(this.cache.json.get('advancewars-rules'));
this.campaign = this.cache.json.get('advancewars-campaign');
this.tex = ensureSheets(this, this.rules);
this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff8c3a, scanlineTint: 0x69d2ff, scanlineAlpha: 0.35 });
this.events.once('shutdown', () => { this.crt.destroy(); this.teardownRun(); });
try {
const data = await (await fetch('data/opponents.json')).json();
for (const o of data.opponents ?? []) this.oppById[o.id] = o;
} catch (_) { /* portraits degrade to sprite/fallback */ }
try {
const res = await api.get('/puzzles/advancewars/progress');
this.levelsCompleted = res?.levelsCompleted ?? 0;
} catch (_) { this.levelsCompleted = 0; }
if (!this.scene.isActive()) return;
this.showMainMenu();
}
// ── screen routing ─────────────────────────────────────────────────────────
swapScreen(builder) {
this.screen?.destroy();
this.screen = builder();
}
showMainMenu() {
this.teardownRun();
this.swapScreen(() => Screens.mainMenu(this, {
hasSave: !!localStorage.getItem(SAVE_KEY),
onCampaign: () => this.showCampaign(),
onWarRoom: () => this.showWarRoom(),
onContinue: () => this.continueSave(),
onLeave: () => this.scene.start('GameMenu'),
}));
}
showCampaign() {
this.swapScreen(() => Screens.campaignScreen(this, this.rules, this.campaign,
this.levelsCompleted, this.oppById, {
onPlay: (idx) => this.showBriefing(idx),
onBack: () => this.showMainMenu(),
}));
}
showBriefing(missionIdx) {
const mission = this.campaign.missions[missionIdx];
this.swapScreen(() => Screens.briefingScreen(this, this.rules, mission, this.oppById, {
onDone: () => this.startMission({ mode: 'campaign', missionIdx }),
}));
}
showWarRoom() {
this.swapScreen(() => Screens.warRoomScreen(this, this.rules, this.campaign,
this.levelsCompleted, this.oppById, {
onStart: (cfg) => this.startMission({ mode: 'warroom', cfg }),
onBack: () => this.showMainMenu(),
}));
}
continueSave() {
try {
const raw = JSON.parse(localStorage.getItem(SAVE_KEY));
const state = Logic.deserialize(raw.state);
this.startMission(raw.meta, state);
} catch (_) {
localStorage.removeItem(SAVE_KEY);
this.showMainMenu();
}
}
// ── mission runtime ────────────────────────────────────────────────────────
missionFor(meta) {
if (meta.mode === 'campaign') return this.campaign.missions[meta.missionIdx];
// war room: mission map with custom COs/fog/skill
const base = this.campaign.missions.find((m) => m.id === meta.cfg.missionId) ??
this.campaign.missions[meta.cfg.mapIdx ?? 0];
return {
...base,
name: `War Room: ${base.name}`,
playerCo: meta.cfg.playerCo,
enemyCos: [meta.cfg.enemyCo],
fog: meta.cfg.fog,
production: true,
startFunds: [3000, 3000],
objective: { type: 'rout' },
dayLimit: 60,
aiProfile: { skill: meta.cfg.skill, aggression: 0.55, captureWeight: 0.5 },
briefing: [],
};
}
startMission(meta, restoredState = null) {
this.swapScreen(() => null);
this.teardownRun();
if (meta.mode === 'warroom' && meta.cfg && !meta.cfg.missionId) {
meta.cfg.missionId = this.campaign.missions[meta.cfg.mapIdx]?.id;
}
const mission = this.missionFor(meta);
const state = restoredState ?? Logic.createGame(this.rules, mission.map, {
cos: [mission.playerCo, ...mission.enemyCos],
fog: mission.fog,
production: mission.production,
startFunds: mission.startFunds,
objective: mission.objective,
dayLimit: mission.dayLimit,
seed: (Date.now() % 100000) + 1,
});
const run = {
meta, mission, state,
mode: 'idle', // idle | selected | target | busy | over
sel: null,
busy: false,
objs: [],
};
this.run = run;
run.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0b0e18, 1).setDepth(0);
run.view = new AdvanceWarsMapView(this, this.rules, state, {
areaX: 16, areaY: 84, areaW: GAME_WIDTH - 320, areaH: GAME_HEIGHT - 200,
});
run.hud = new AdvanceWarsHUD(this, this.rules, {
onEndTurn: () => this.onEndTurn(),
onMenu: () => this.openPauseMenu(),
onPower: () => this.onPower(),
});
run.hud.uiKey = run.view.tex.ui;
run.hud.buildStars(this.rules, state);
run.hud.attachPortraits(
this.oppById[this.rules.coById[state.armies[0].co].opponentId],
this.oppById[this.rules.coById[state.armies[1].co].opponentId]);
run.hud.setObjective(OBJECTIVE_TEXT[state.objective.type] ?? '');
run.menu = new ActionMenu(this);
run.preview = new DamagePreview(this);
run.prod = new ProductionMenu(this, this.rules, run.view.tex.units);
run.tileInfo = new TileInfo(this, this.rules);
this.bindBoardInput();
this.refreshAll();
this.saveGame();
}
teardownRun() {
const run = this.run;
if (!run) return;
this.input.off('pointermove', this.onPointerMove, this);
this.input.off('pointerdown', this.onPointerDown, this);
run.view?.destroy();
run.hud?.destroy();
run.menu?.close();
run.preview?.hide();
run.prod?.close();
run.tileInfo?.destroy();
run.bg?.destroy();
for (const o of run.objs) o?.destroy?.();
this.run = null;
}
refreshAll() {
const run = this.run;
if (!run) return;
run.view.syncUnits();
run.view.refreshFog(0);
run.hud.refresh(run.state, 0);
}
saveGame() {
const run = this.run;
if (!run || run.state.result) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({
meta: run.meta,
state: Logic.serialize(run.state),
}));
} catch (_) { /* storage full */ }
}
// ── input ──────────────────────────────────────────────────────────────────
bindBoardInput() {
this.input.on('pointermove', this.onPointerMove, this);
this.input.on('pointerdown', this.onPointerDown, this);
}
onPointerMove(p) {
const run = this.run;
if (!run || run.busy) return;
const tile = run.view.tileAt(p.worldX, p.worldY);
run.view.setCursor(tile);
if (tile) {
const u = Logic.unitAt(run.state, tile.x, tile.y);
const spotted = u && Logic.visibleUnits(this.rules, run.state, 0).includes(u) ? u : null;
run.tileInfo.show(run.state, tile.x, tile.y, spotted);
}
}
onPointerDown(p, objects) {
const run = this.run;
if (!run || run.busy || run.state.result) return;
if (objects?.length) return; // a UI object handled it
if (run.menu.isOpen || run.preview.isOpen || run.prod.isOpen) {
run.menu.close(); run.preview.hide();
this.cancelSelection();
return;
}
if (run.state.turn !== 0) return;
const tile = run.view.tileAt(p.worldX, p.worldY);
if (!tile) return;
if (run.mode === 'target') { this.onTargetClick(tile); return; }
if (run.mode === 'selected') { this.onDestinationClick(tile, p); return; }
this.onIdleClick(tile);
}
onIdleClick(tile) {
const run = this.run;
const unit = Logic.unitAt(run.state, tile.x, tile.y);
if (unit && unit.army === 0 && !unit.moved) {
run.sel = {
unit,
reach: Logic.reachableTiles(this.rules, run.state, unit),
};
run.mode = 'selected';
run.view.showMoveRange([...run.sel.reach.dist.keys()]
.filter((k) => Logic.canStopAt(run.state, unit, k % run.state.w, Math.floor(k / run.state.w))));
// indirects: preview firing range from where they stand
const spec = this.rules.unitById[unit.type];
if (spec.indirect) {
const range = Logic.effectiveRange(this.rules, run.state, unit);
const tiles = [];
for (let y = 0; y < run.state.h; y++) {
for (let x = 0; x < run.state.w; x++) {
const d = Math.abs(x - unit.x) + Math.abs(y - unit.y);
if (d >= range[0] && d <= range[1]) tiles.push({ x, y });
}
}
run.view.showAttackTiles(tiles);
}
return;
}
if (unit && unit.army === 0 && unit.moved) return;
if (!unit) {
// factory?
const options = Logic.buildOptions(this.rules, run.state, tile.x, tile.y);
if (options.length) {
run.prod.open(options, run.state.armies[0].funds,
(type) => this.doAction({ type: 'build', x: tile.x, y: tile.y, unitType: type }),
() => {});
}
}
}
onDestinationClick(tile, pointer) {
const run = this.run;
const { unit, reach } = run.sel;
const key = Logic.tileKey(run.state, tile.x, tile.y);
const occ = Logic.unitAt(run.state, tile.x, tile.y);
// clicking the unit itself = act in place
const inPlace = occ === unit;
if (!inPlace && !reach.dist.has(key)) { this.cancelSelection(); return; }
// friendly transport → load; friendly same-type → join
if (occ && occ !== unit && occ.army === 0) {
const tspec = this.rules.unitById[occ.type];
const canLoad = tspec.transport && occ.cargo.length < tspec.transport.cap &&
(tspec.transport.carries === 'landUnits'
? this.rules.unitById[unit.type].domain === 'land'
: tspec.transport.carries.includes(unit.type));
const canJoin = occ.type === unit.type && occ.hp < 100;
if (!canLoad && !canJoin) { this.cancelSelection(); return; }
const adj = this.bestAdjacentStop(unit, reach, tile.x, tile.y);
if (!adj) { this.cancelSelection(); return; }
const path = adj.key === reach.start ? [] : Logic.pathFromReach(run.state, reach, adj.key);
const opts = [];
if (canLoad) opts.push({ label: 'LOAD', cb: () => this.doAction({ type: 'load', unitId: unit.id, path, x: tile.x, y: tile.y }) });
if (canJoin) opts.push({ label: 'JOIN', cb: () => this.doAction({ type: 'join', unitId: unit.id, path, x: tile.x, y: tile.y }) });
opts.push({ label: 'CANCEL', cb: () => this.cancelSelection() });
run.menu.open(pointer.worldX, pointer.worldY, opts);
return;
}
if (!inPlace && !Logic.canStopAt(run.state, unit, tile.x, tile.y)) { this.cancelSelection(); return; }
const path = inPlace ? [] : Logic.pathFromReach(run.state, reach, key);
run.sel.pending = { x: tile.x, y: tile.y, path };
run.view.showPath(unit, path);
this.openActionMenuAt(pointer.worldX, pointer.worldY);
}
bestAdjacentStop(unit, reach, tx, ty) {
const run = this.run;
let best = null;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const x = tx + dx, y = ty + dy;
if (!Logic.inBounds(run.state, x, y)) continue;
const k = Logic.tileKey(run.state, x, y);
const isStart = k === reach.start;
if (!isStart && !reach.dist.has(k)) continue;
if (!isStart && !Logic.canStopAt(run.state, unit, x, y)) continue;
const d = isStart ? 0 : reach.dist.get(k);
if (!best || d < best.d) best = { key: k, d };
}
return best;
}
openActionMenuAt(px, py) {
const run = this.run;
const { unit } = run.sel;
const { x, y, path } = run.sel.pending;
const spec = this.rules.unitById[unit.type];
const movedTiles = path.length > 0;
const opts = [];
const targets = spec.range
? Logic.attackTargetsFrom(this.rules, run.state, unit, x, y, { afterMove: movedTiles })
: [];
if (targets.length) opts.push({ label: 'FIRE', cb: () => this.enterTargetMode(targets) });
const k = Logic.tileKey(run.state, x, y);
const t = this.rules.terrains[run.state.terrain[k]];
if (spec.capture && t.property && run.state.owner[k] !== 0 &&
(run.state.owner[k] < 0 || Logic.hostile(run.state, run.state.owner[k], 0))) {
opts.push({ label: 'CAPTURE', cb: () => this.doAction({ type: 'capture', unitId: unit.id, path }) });
}
if (spec.supplies) {
opts.push({ label: 'SUPPLY', cb: () => this.doAction({ type: 'supply', unitId: unit.id, path }) });
}
if (spec.transport && unit.cargo.length) {
const drops = this.autoDrops(unit, x, y);
if (drops.length) {
opts.push({ label: 'UNLOAD', cb: () => this.doAction({ type: 'unload', unitId: unit.id, path, drops }) });
}
}
if (spec.dive && !unit.dived) opts.push({ label: 'DIVE', cb: () => this.doAction({ type: 'dive', unitId: unit.id, path }) });
if (unit.dived) opts.push({ label: 'SURFACE', cb: () => this.doAction({ type: 'rise', unitId: unit.id, path }) });
opts.push({ label: 'WAIT', cb: () => this.doAction({ type: 'wait', unitId: unit.id, path }) });
opts.push({ label: 'CANCEL', cb: () => this.cancelSelection() });
run.menu.open(px, py, opts);
}
autoDrops(unit, x, y) {
const run = this.run;
const drops = [];
const used = new Set();
for (let ci = unit.cargo.length - 1; ci >= 0; ci--) {
const cargo = unit.cargo[ci];
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
const nx = x + dx, ny = y + dy;
if (!Logic.inBounds(run.state, nx, ny) || used.has(`${nx},${ny}`)) continue;
const t = Logic.terrainAt(this.rules, run.state, nx, ny);
if (t.cost[this.rules.unitById[cargo.type].moveType] == null) continue;
const occ = Logic.unitAt(run.state, nx, ny);
if (occ && occ !== unit) continue;
used.add(`${nx},${ny}`);
drops.push({ cargoIndex: ci, x: nx, y: ny });
break;
}
}
return drops;
}
enterTargetMode(targets) {
const run = this.run;
run.mode = 'target';
run.targets = targets;
run.view.showAttackTiles(targets.map((t) => ({ x: t.x, y: t.y })));
}
onTargetClick(tile) {
const run = this.run;
const target = run.targets?.find((t) => t.x === tile.x && t.y === tile.y);
if (!target) { this.cancelSelection(); return; }
const { unit } = run.sel;
const { x, y, path } = run.sel.pending;
const probe = { ...unit, x, y };
const deal = Logic.computeDamage(this.rules, run.state, probe, target, 0);
let counter = null;
const tspec = this.rules.unitById[target.type];
if (deal && !tspec.indirect && tspec.range &&
Math.abs(target.x - x) + Math.abs(target.y - y) === 1 && target.hp - deal.dmg > 0) {
const cres = Logic.computeDamage(this.rules, run.state,
{ ...target, hp: Math.max(1, target.hp - deal.dmg) }, probe, 0);
counter = cres ? Math.min(100, cres.dmg) : null;
}
run.preview.show(run.view.px(target.x), run.view.py(target.y),
Math.min(100, deal?.dmg ?? 0), counter,
() => this.doAction({ type: 'attack', unitId: unit.id, path, targetId: target.id }),
() => { run.preview.hide(); this.cancelSelection(); });
}
cancelSelection() {
const run = this.run;
if (!run) return;
run.menu.close();
run.preview.hide();
run.view.clearOverlays();
run.sel = null;
run.targets = null;
run.mode = 'idle';
run.view.syncUnits();
}
// ── executing actions ─────────────────────────────────────────────────────
async doAction(action) {
const run = this.run;
if (!run || run.busy) return;
run.menu.close();
run.preview.hide();
run.view.clearOverlays();
run.busy = true;
const res = Logic.applyAction(run.state, this.rules, action);
run.sel = null;
run.mode = 'idle';
if (res.ok) {
await this.replayEvents([{ action, events: res.events }], { animateOwn: true });
}
run.busy = false;
this.refreshAll();
if (run.state.result) { this.onMissionOver(); return; }
}
onEndTurn() {
const run = this.run;
if (!run || run.busy || run.state.turn !== 0 || run.state.result) return;
this.cancelSelection();
this.runEnemyTurns();
}
onPower() {
if (!this.run || this.run.busy) return;
this.doAction({ type: 'power' });
}
async runEnemyTurns() {
const run = this.run;
run.busy = true;
const endRes = Logic.applyAction(run.state, this.rules, { type: 'endTurn' });
await this.replayEvents([{ action: { type: 'endTurn' }, events: endRes.events }], {});
run.hud.refresh(run.state, 0);
while (!run.state.result && run.state.turn !== 0 && this.run === run) {
const army = run.state.turn;
const profile = run.mission.aiProfile ?? { skill: 3, aggression: 0.5, captureWeight: 0.4 };
const log = runAITurn(this.rules, run.state, army, profile);
await this.replayEvents(log, { enemy: true });
}
if (this.run !== run) return;
run.busy = false;
this.refreshAll();
if (run.state.result) { this.onMissionOver(); return; }
this.saveGame();
}
// Replays engine events entry by entry. State is already final; views are
// nudged per-event so the player can follow along.
async replayEvents(log, { enemy = false } = {}) {
const run = this.run;
for (const entry of log) {
if (this.run !== run) return;
const events = entry.events ?? [];
const moved = events.find((e) => e.type === 'moved');
if (moved && (!run.state.fog || this.pathPartlyVisible(moved))) {
await new Promise((resolve) => run.view.animateMove(moved.unitId, moved.path, resolve));
}
const battles = events.filter((e) => e.type === 'battle');
if (battles.length) {
const anyVisible = !run.state.fog || battles.some((b) =>
Logic.computeVision(this.rules, run.state, 0).has(Logic.tileKey(run.state, b.defender.x, b.defender.y)));
if (this.battleAnims && anyVisible) {
await new Promise((resolve) => playBattleAnim(this, this.rules, run.view.tex.units, battles, resolve));
}
this.crt.pulse(0.6, 260);
}
for (const e of events) {
if (e.type === 'destroyed' || e.type === 'crashed') {
run.view.boom(e.x, e.y);
const v = run.view.unitViews.get(e.unitId);
if (v) { v.c.destroy(); run.view.unitViews.delete(e.unitId); }
}
if (e.type === 'powerFired') {
await new Promise((resolve) => Screens.powerCutIn(this, this.rules, this.oppById, run.state.armies[e.army].co, e.army, resolve));
run.hud.refresh(run.state, 0);
}
if (e.type === 'captured' || e.type === 'capturing') run.view.refreshProps();
if (e.type === 'meteor' || e.type === 'tsunami') this.crt.pulse(1.0, 500);
if (e.type === 'dayStart' && e.army === 0) this.saveGame();
}
run.view.syncUnits();
run.view.refreshFog(0);
run.hud.refresh(run.state, 0);
if (enemy && (moved || battles.length)) await this.wait(140);
}
}
pathPartlyVisible(movedEvent) {
const run = this.run;
const vis = Logic.computeVision(this.rules, run.state, 0);
const pts = [movedEvent.from, ...(movedEvent.path ?? []), movedEvent.to];
return pts.some((p) => vis.has(Logic.tileKey(run.state, p.x, p.y)));
}
wait(ms) { return new Promise((r) => this.time.delayedCall(ms, r)); }
// ── pause / mission end ───────────────────────────────────────────────────
openPauseMenu() {
const run = this.run;
if (!run || run.busy) return;
this.cancelSelection();
run.menu.open(GAME_WIDTH / 2 - 240, GAME_HEIGHT / 2 - 120, [
{ label: 'RESUME', cb: () => {} },
{
label: `ANIMS: ${this.battleAnims ? 'ON' : 'OFF'}`,
cb: () => {
this.battleAnims = !this.battleAnims;
localStorage.setItem(ANIM_KEY, this.battleAnims ? '1' : '0');
},
},
{ label: 'SAVE + MENU', cb: () => { this.saveGame(); this.showMainMenu(); } },
{ label: 'SURRENDER', danger: true, cb: () => { run.state.result = { winner: 'enemy', reason: 'surrender' }; this.onMissionOver(); } },
]);
}
onMissionOver() {
const run = this.run;
if (!run) return;
const won = run.state.result.winner === 'player';
localStorage.removeItem(SAVE_KEY);
const days = run.state.day;
const limit = run.state.dayLimit || 60;
const rank = days <= limit * 0.4 ? 'S' : days <= limit * 0.6 ? 'A' : days <= limit * 0.8 ? 'B' : 'C';
if (won && run.meta.mode === 'campaign') {
const level = run.meta.missionIdx + 1;
if (level === this.levelsCompleted + 1) this.levelsCompleted = level;
api.post('/puzzles/advancewars/complete', { level }).catch(() => {});
}
const meta = run.meta;
const mission = run.mission;
this.time.delayedCall(700, () => {
this.teardownRun();
this.swapScreen(() => Screens.resultScreen(this, this.rules, this.oppById, {
won, mission, days, rank,
onContinue: () => {
if (meta.mode === 'campaign' && meta.missionIdx + 1 < this.campaign.missions.length) this.showCampaign();
else this.showMainMenu();
},
onRetry: () => {
if (meta.mode === 'campaign') this.showBriefing(meta.missionIdx);
else this.startMission(meta);
},
onMenu: () => this.showMainMenu(),
}));
});
}
}

View File

@ -0,0 +1,953 @@
// 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,
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');
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));
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, 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);
return rules.units
.filter((u) => (u.domain === 'air' ? 'air' : u.domain === 'sea' ? 'sea' : 'land') === t.builds)
.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;
}

View File

@ -0,0 +1,520 @@
// Advance Wars board renderer: terrain (RenderTexture), buildings (live
// tinted images), units (containers with 2-frame flip + HP digits), fog,
// move/attack overlays, path arrow and cursor.
//
// Art comes from FOUR drop-in sheets (data/advancewars-artwork.json →
// terrainSheet 48×48, buildingSheet 48×64 tintable, unitSheet 48×64
// tintable, uiSheet 48×48; spec: src/games/advancewars/sprites.md). Any
// sheet not painted yet gets a procedural stand-in with the same frame map.
// Rivers and roads are NOT sprites — they're drawn with Graphics from the
// tile connection masks.
import * as Phaser from 'phaser';
import * as Logic from './AdvanceWarsLogic.js';
const CELL = 48;
const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom
// Painted drop-in sheets and the layout their procedural stand-ins mirror.
export const SHEETS = {
terrain: { key: 'advancewars-terrain', cellH: CELL, cols: 8, frames: 8 },
buildings: { key: 'advancewars-buildings', cellH: TALL, cols: 6, frames: 5 },
units: { key: 'advancewars-units', cellH: TALL, cols: 12, frames: 36 },
ui: { key: 'advancewars-ui', cellH: CELL, cols: 8, frames: 7 },
};
export const UI_FRAMES = {
cursor: 0, explosion1: 1, explosion2: 2, capture: 3, fuel: 4, ammo: 5, star: 6,
};
export function armyColorInt(rules, army) {
const hex = army < 0 ? rules.constants.neutralColor : rules.constants.armyColors[army];
return parseInt(hex.slice(1), 16);
}
// ---------------------------------------------------------------------------
// Procedural stand-in sheets
// Returns { terrain, buildings, units, ui } texture keys — the painted sheet
// when it's loaded, otherwise a generated stand-in.
export function ensureSheets(scene, rules) {
const keys = {};
for (const [name, spec] of Object.entries(SHEETS)) {
if (scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
const procKey = `${spec.key}-proc`;
if (!scene.textures.exists(procKey)) PROC_PAINTERS[name](scene, procKey, spec, rules);
keys[name] = procKey;
}
return keys;
}
function mkCanvasSheet(scene, key, spec, count) {
const rows = Math.ceil(count / spec.cols);
const tex = scene.textures.createCanvas(key, spec.cols * CELL, rows * spec.cellH);
const ctx = tex.getContext();
const at = (frame, draw) => {
const fx = (frame % spec.cols) * CELL;
const fy = Math.floor(frame / spec.cols) * spec.cellH;
ctx.save();
// origin at the top-left of the 48×48 footprint (tall cells have 16px
// of headroom above it)
ctx.translate(fx, fy + (spec.cellH - CELL));
draw(ctx);
ctx.restore();
};
const finish = () => {
tex.refresh();
for (let f = 0; f < count; f++) {
tex.add(f, 0, (f % spec.cols) * CELL, Math.floor(f / spec.cols) * spec.cellH, CELL, spec.cellH);
}
};
return { at, finish };
}
const ground = (c, color) => { c.fillStyle = color; c.fillRect(0, 0, CELL, CELL); };
const PROC_PAINTERS = {
terrain(scene, key, spec) {
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
at(0, (c) => { ground(c, '#a8d060'); c.fillStyle = '#b8dc74'; for (let i = 0; i < 5; i++) c.fillRect(6 + i * 8, 10 + (i % 3) * 12, 3, 2); });
at(1, (c) => {
ground(c, '#8cc456');
c.fillStyle = '#2f6b22';
for (const [tx, ty] of [[12, 8], [30, 4], [21, 18]]) {
c.beginPath(); c.moveTo(tx, ty + 22); c.lineTo(tx + 10, ty + 22); c.lineTo(tx + 5, ty + 2); c.fill();
c.fillRect(tx + 3.5, ty + 22, 3, 5);
}
});
at(2, (c) => {
ground(c, '#b5d16b');
c.fillStyle = '#8a6a3c';
c.beginPath(); c.moveTo(3, 45); c.lineTo(24, 4); c.lineTo(45, 45); c.fill();
c.fillStyle = '#f0ede2';
c.beginPath(); c.moveTo(19, 14); c.lineTo(24, 4); c.lineTo(29, 14); c.lineTo(24, 19); c.fill();
});
at(3, (c) => { ground(c, '#3d6fd6'); c.strokeStyle = '#6f9ae8'; c.lineWidth = 2; for (let i = 0; i < 3; i++) { c.beginPath(); c.moveTo(4 + i * 6, 12 + i * 12); c.quadraticCurveTo(14 + i * 6, 8 + i * 12, 24 + i * 6, 12 + i * 12); c.stroke(); } });
at(4, (c) => { ground(c, '#3d6fd6'); c.fillStyle = '#24488f'; for (const [px, py] of [[10, 12], [26, 8], [18, 26], [34, 30], [30, 18]]) { c.beginPath(); c.arc(px, py, 4, 0, 7); c.fill(); } });
at(5, (c) => { ground(c, '#e8d9a0'); c.fillStyle = '#8fb8e8'; c.fillRect(0, 0, CELL, 8); c.fillStyle = '#f4e9c0'; c.fillRect(0, 8, CELL, 6); });
const bridge = (vert) => (c) => {
ground(c, '#3d6fd6');
c.fillStyle = '#b0a89a';
if (vert) c.fillRect(12, 0, 24, CELL); else c.fillRect(0, 12, CELL, 24);
c.fillStyle = '#8a8276';
if (vert) { c.fillRect(12, 0, 4, CELL); c.fillRect(32, 0, 4, CELL); } else { c.fillRect(0, 12, CELL, 4); c.fillRect(0, 32, CELL, 4); }
};
at(6, bridge(false));
at(7, bridge(true));
finish();
},
buildings(scene, key, spec) {
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
const prop = (drawIcon) => (c) => {
ground(c, '#a8d060');
c.fillStyle = '#ececec'; c.strokeStyle = '#3a3a3a'; c.lineWidth = 2;
c.fillRect(6, -6, 36, 42); c.strokeRect(6, -6, 36, 42);
drawIcon(c);
};
at(0, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(12, 2, 10, 26); c.fillRect(26, 10, 10, 18); c.fillStyle = '#3a3a3a'; for (let i = 0; i < 3; i++) { c.fillRect(14, 5 + i * 7, 6, 3); c.fillRect(28, 13 + i * 6, 6, 3); } }));
at(1, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(10, 12, 28, 16); c.beginPath(); c.moveTo(10, 12); c.lineTo(17, 2); c.lineTo(24, 12); c.lineTo(31, 2); c.lineTo(38, 12); c.fill(); c.fillStyle = '#3a3a3a'; c.fillRect(20, 18, 8, 10); }));
at(2, prop((c) => { c.fillStyle = '#8a8a8a'; c.beginPath(); c.moveTo(24, 0); c.lineTo(28, 12); c.lineTo(40, 16); c.lineTo(28, 20); c.lineTo(24, 32); c.lineTo(20, 20); c.lineTo(8, 16); c.lineTo(20, 12); c.fill(); }));
at(3, prop((c) => { c.strokeStyle = '#8a8a8a'; c.lineWidth = 4; c.beginPath(); c.arc(24, 14, 9, 0.5, Math.PI - 0.5, false); c.stroke(); c.beginPath(); c.moveTo(24, 0); c.lineTo(24, 20); c.stroke(); c.beginPath(); c.moveTo(16, 6); c.lineTo(32, 6); c.stroke(); }));
at(4, prop((c) => { c.fillStyle = '#8a8a8a'; c.fillRect(21, -4, 4, 34); c.fillStyle = '#c8c8c8'; c.beginPath(); c.moveTo(25, -2); c.lineTo(41, 4); c.lineTo(25, 10); c.fill(); }));
finish();
},
units(scene, key, spec, rules) {
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
const unitGlyph = (abbr, domain) => (c, bob) => {
const y0 = -bob * 3;
c.fillStyle = '#f0f0f0'; c.strokeStyle = '#26262e'; c.lineWidth = 2.5;
if (domain === 'air') {
c.beginPath(); c.ellipse(24, 22 + y0, 18, 11, 0, 0, 7); c.fill(); c.stroke();
c.beginPath(); c.moveTo(6, 22 + y0); c.lineTo(0, 34 + y0); c.lineTo(14, 28 + y0); c.fill();
} else if (domain === 'sea') {
c.beginPath(); c.moveTo(2, 26 + y0); c.lineTo(46, 26 + y0); c.lineTo(38, 40 + y0); c.lineTo(10, 40 + y0); c.fill(); c.stroke();
c.fillRect(16, 14 + y0, 16, 12);
} else {
c.beginPath(); c.roundRect(6, 12 + y0, 36, 26, 7); c.fill(); c.stroke();
}
c.fillStyle = '#26262e';
c.font = 'bold 15px monospace';
c.textAlign = 'center';
c.fillText(abbr, 24, 31 + y0);
};
for (const u of rules.units) {
const draw = unitGlyph(u.abbr, u.domain);
at(u.frame, (c) => draw(c, 0));
at(u.frame + 1, (c) => draw(c, 1));
}
finish();
},
ui(scene, key, spec) {
const { at, finish } = mkCanvasSheet(scene, key, spec, spec.frames);
at(UI_FRAMES.cursor, (c) => {
c.strokeStyle = '#ffffff'; c.lineWidth = 4;
for (const [sx, sy, dx, dy] of [[3, 3, 1, 1], [45, 3, -1, 1], [3, 45, 1, -1], [45, 45, -1, -1]]) {
c.beginPath(); c.moveTo(sx + dx * 12, sy); c.lineTo(sx, sy); c.lineTo(sx, sy + dy * 12); c.stroke();
}
});
const boom = (r) => (c) => {
c.fillStyle = '#ffb02f';
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
c.beginPath(); c.arc(24 + Math.cos(a) * r, 24 + Math.sin(a) * r, r * 0.55, 0, 7); c.fill();
}
c.fillStyle = '#ff5a2f'; c.beginPath(); c.arc(24, 24, r * 0.8, 0, 7); c.fill();
};
at(UI_FRAMES.explosion1, boom(8));
at(UI_FRAMES.explosion2, boom(15));
at(UI_FRAMES.capture, (c) => { c.fillStyle = '#fff'; c.fillRect(10, 8, 4, 32); c.fillStyle = '#ff4040'; c.beginPath(); c.moveTo(14, 8); c.lineTo(38, 15); c.lineTo(14, 22); c.fill(); });
at(UI_FRAMES.fuel, (c) => { c.fillStyle = '#ffce4d'; c.beginPath(); c.moveTo(26, 2); c.lineTo(12, 26); c.lineTo(22, 26); c.lineTo(18, 46); c.lineTo(36, 20); c.lineTo(25, 20); c.fill(); });
at(UI_FRAMES.ammo, (c) => { c.fillStyle = '#e05050'; c.fillRect(18, 14, 12, 24); c.beginPath(); c.moveTo(18, 14); c.lineTo(24, 2); c.lineTo(30, 14); c.fill(); });
at(UI_FRAMES.star, (c) => {
c.fillStyle = '#ffd94d'; c.strokeStyle = '#8a6d00'; c.lineWidth = 2;
c.beginPath();
for (let i = 0; i < 10; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 5;
const r = i % 2 ? 9 : 20;
c[i ? 'lineTo' : 'moveTo'](24 + Math.cos(a) * r, 24 + Math.sin(a) * r);
}
c.closePath(); c.fill(); c.stroke();
});
finish();
},
};
// ---------------------------------------------------------------------------
// Board view
export class AdvanceWarsMapView {
constructor(scene, rules, state, opts) {
this.scene = scene;
this.rules = rules;
this.state = state;
this.tex = ensureSheets(scene, rules);
const { areaX, areaY, areaW, areaH } = opts;
this.tile = Math.max(24, Math.min(84, Math.floor(areaW / state.w), Math.floor(areaH / state.h)));
this.ox = areaX + Math.floor((areaW - this.tile * state.w) / 2);
this.oy = areaY + Math.floor((areaH - this.tile * state.h) / 2);
this.depths = { terrain: 1, props: 2, moveOv: 3, atkOv: 4, units: 5, fog: 12, path: 13, cursor: 14 };
this.unitViews = new Map(); // unitId -> { c, sprite, hp, badge }
this.propViews = new Map(); // tileKey -> image
this.animStep = 0;
this.buildTerrain();
this.buildProps();
this.moveG = scene.add.graphics().setDepth(this.depths.moveOv);
this.atkG = scene.add.graphics().setDepth(this.depths.atkOv);
this.fogG = scene.add.graphics().setDepth(this.depths.fog);
this.pathG = scene.add.graphics().setDepth(this.depths.path);
this.cursor = scene.add.image(0, 0, this.tex.ui, UI_FRAMES.cursor)
.setDisplaySize(this.tile, this.tile)
.setDepth(this.depths.cursor).setVisible(false);
this.syncUnits();
this.flipTimer = scene.time.addEvent({
delay: 450, loop: true, callback: () => {
this.animStep = 1 - this.animStep;
for (const [id, v] of this.unitViews) {
const u = Logic.unitById(this.state, id);
if (u) v.sprite.setFrame(this.rules.unitById[u.type].frame + this.animStep);
}
},
});
}
destroy() {
this.flipTimer?.remove();
this.rt?.destroy();
this.moveG?.destroy(); this.atkG?.destroy(); this.fogG?.destroy();
this.pathG?.destroy(); this.cursor?.destroy();
for (const v of this.unitViews.values()) v.c.destroy();
for (const img of this.propViews.values()) img.destroy();
}
// pixel center of a tile
px(x) { return this.ox + x * this.tile + this.tile / 2; }
py(y) { return this.oy + y * this.tile + this.tile / 2; }
tileAt(worldX, worldY) {
const x = Math.floor((worldX - this.ox) / this.tile);
const y = Math.floor((worldY - this.oy) / this.tile);
return Logic.inBounds(this.state, x, y) ? { x, y } : null;
}
spriteScaleFor() { return this.tile / CELL; }
buildTerrain() {
const { scene, state, rules } = this;
const w = state.w * this.tile, h = state.h * this.tile;
this.rt = scene.add.renderTexture(this.ox, this.oy, w, h).setOrigin(0, 0).setDepth(this.depths.terrain);
this.rt.beginDraw();
for (let y = 0; y < state.h; y++) {
for (let x = 0; x < state.w; x++) {
const t = rules.terrains[state.terrain[y * state.w + x]];
// rivers/roads/buildings sit on plain ground; the channels are drawn
// by Graphics below and buildings are live tinted images
const frame = (t.property || t.id === 'river' || t.id === 'road')
? rules.terrainById.plain.frame
: t.frame;
this.drawCell(frame, x, y);
}
}
this.rt.endDraw();
this.drawChannels();
this.drawShores();
}
drawCell(frame, x, y) {
const scale = this.tile / CELL;
const img = this.scene.make.image({ key: this.tex.terrain, frame, add: false });
img.setOrigin(0, 0).setScale(scale);
this.rt.batchDraw(img, x * this.tile, y * this.tile);
img.destroy();
}
// Graphics-drawn rivers and roads (no sprite frames): each tile strokes
// from its center toward every connected edge.
drawChannels() {
const { state, rules } = this;
const g = this.scene.add.graphics();
const draw = (kind, color, width) => {
g.lineStyle(width, color, 1);
g.fillStyle(color, 1);
for (let y = 0; y < state.h; y++) {
for (let x = 0; x < state.w; x++) {
const t = rules.terrains[state.terrain[y * state.w + x]];
if (t.id !== kind) continue;
const cx = x * this.tile + this.tile / 2;
const cy = y * this.tile + this.tile / 2;
let any = false;
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
if (!this.channelJoins(kind, x + dx, y + dy)) continue;
any = true;
g.beginPath();
g.moveTo(cx, cy);
g.lineTo(cx + dx * this.tile / 2, cy + dy * this.tile / 2);
g.strokePath();
}
if (!any) { // isolated stub renders as an east-west segment
g.beginPath(); g.moveTo(cx - this.tile / 2, cy); g.lineTo(cx + this.tile / 2, cy); g.strokePath();
}
g.fillRect(cx - width / 2, cy - width / 2, width, width);
}
}
};
draw('river', 0x7db4e8, Math.max(5, this.tile * 0.34));
draw('road', 0xd8cfc0, Math.max(6, this.tile * 0.42));
this.rt.draw(g, 0, 0);
g.destroy();
}
channelJoins(kind, nx, ny) {
const { state, rules } = this;
if (!Logic.inBounds(state, nx, ny)) return false;
const n = rules.terrains[state.terrain[ny * state.w + nx]];
if (kind === 'river') return n.id === 'river' || n.id === 'sea' || n.id === 'bridgeh' || n.id === 'bridgev';
return n.id === 'road' || n.id === 'bridgeh' || n.id === 'bridgev' || n.property;
}
drawShores() {
const { state, rules } = this;
const g = this.scene.add.graphics();
g.lineStyle(Math.max(2, this.tile * 0.08), 0xf4e9c0, 0.9);
for (let y = 0; y < state.h; y++) {
for (let x = 0; x < state.w; x++) {
const t = rules.terrains[state.terrain[y * state.w + x]];
if (t.id !== 'sea' && t.id !== 'reef') continue;
const px = x * this.tile, py = y * this.tile;
const land = (nx, ny) => Logic.inBounds(state, nx, ny) &&
!['sea', 'reef', 'bridgeh', 'bridgev'].includes(rules.terrains[state.terrain[ny * state.w + nx]].id);
if (land(x, y - 1)) { g.beginPath(); g.moveTo(px, py + 1); g.lineTo(px + this.tile, py + 1); g.strokePath(); }
if (land(x, y + 1)) { g.beginPath(); g.moveTo(px, py + this.tile - 1); g.lineTo(px + this.tile, py + this.tile - 1); g.strokePath(); }
if (land(x - 1, y)) { g.beginPath(); g.moveTo(px + 1, py); g.lineTo(px + 1, py + this.tile); g.strokePath(); }
if (land(x + 1, y)) { g.beginPath(); g.moveTo(px + this.tile - 1, py); g.lineTo(px + this.tile - 1, py + this.tile); g.strokePath(); }
}
}
this.rt.draw(g, 0, 0);
g.destroy();
}
buildProps() {
const { state, rules } = this;
for (let k = 0; k < state.terrain.length; k++) {
const t = rules.terrains[state.terrain[k]];
if (!t.property) continue;
const x = k % state.w, y = Math.floor(k / state.w);
const img = this.scene.add.image(this.px(x), this.oy + (y + 1) * this.tile, this.tex.buildings, t.frame)
.setOrigin(0.5, 1)
.setScale(this.spriteScaleFor())
.setDepth(this.depths.props);
this.propViews.set(k, img);
}
this.refreshProps();
}
refreshProps() {
for (const [k, img] of this.propViews) {
img.setTint(armyColorInt(this.rules, this.state.owner[k]));
}
}
// ── units ────────────────────────────────────────────────────────────────
syncUnits() {
const { state, rules, scene } = this;
const seen = new Set();
for (const u of state.units) {
seen.add(u.id);
let v = this.unitViews.get(u.id);
if (!v) {
const c = scene.add.container(0, 0).setDepth(this.depths.units);
const sprite = scene.add.image(0, 0, this.tex.units, rules.unitById[u.type].frame)
.setOrigin(0.5, 1).setScale(this.spriteScaleFor());
const hp = scene.add.text(this.tile * 0.30, -2, '', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: `${Math.max(13, this.tile * 0.34)}px`,
color: '#ffffff', stroke: '#000000', strokeThickness: 3,
}).setOrigin(0.5, 1);
const badge = scene.add.image(-this.tile * 0.32, -this.tile * 0.16, this.tex.ui, UI_FRAMES.capture)
.setScale(this.spriteScaleFor() * 0.4).setVisible(false);
c.add([sprite, hp, badge]);
v = { c, sprite, hp, badge };
this.unitViews.set(u.id, v);
}
this.placeUnit(u, v);
}
for (const [id, v] of [...this.unitViews]) {
if (!seen.has(id)) { v.c.destroy(); this.unitViews.delete(id); }
}
this.refreshProps();
}
placeUnit(u, v = this.unitViews.get(u.id)) {
if (!v) return;
const { rules } = this;
v.c.setPosition(this.px(u.x), this.oy + (u.y + 1) * this.tile);
v.sprite.setFrame(rules.unitById[u.type].frame + this.animStep);
v.sprite.setFlipX(u.army !== 0);
const base = armyColorInt(rules, u.army);
v.sprite.setTint(u.moved && u.army === this.state.turn ? darken(base, 0.5) : base);
v.sprite.setAlpha(u.dived ? 0.5 : 1);
const hpd = Logic.hpDisplay(u);
v.hp.setText(hpd < 10 ? String(hpd) : '');
const spec = rules.unitById[u.type];
const lowFuel = u.fuel <= spec.fuel * 0.25 && (spec.dailyFuel ?? 0) > 0;
const lowAmmo = spec.ammo > 0 && u.ammo <= 1;
v.badge.setVisible(!!(u.capturing || lowFuel || lowAmmo || u.cargo.length));
if (u.capturing) v.badge.setFrame(UI_FRAMES.capture);
else if (u.cargo.length) v.badge.setFrame(UI_FRAMES.star);
else if (lowFuel) v.badge.setFrame(UI_FRAMES.fuel);
else if (lowAmmo) v.badge.setFrame(UI_FRAMES.ammo);
}
setUnitVisibility(visibleIds) {
for (const [id, v] of this.unitViews) {
v.c.setVisible(visibleIds.has(id));
}
}
// ── overlays ─────────────────────────────────────────────────────────────
showMoveRange(keys) {
this.moveG.clear();
this.moveG.fillStyle(0x3d8bff, 0.38);
for (const key of keys) {
const x = key % this.state.w, y = Math.floor(key / this.state.w);
this.moveG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2);
}
}
showAttackTiles(tiles) {
this.atkG.clear();
this.atkG.fillStyle(0xff3d3d, 0.42);
for (const { x, y } of tiles) {
this.atkG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2);
}
}
clearOverlays() {
this.moveG.clear();
this.atkG.clear();
this.pathG.clear();
}
showPath(unit, path) {
this.pathG.clear();
if (!path?.length) return;
this.pathG.lineStyle(Math.max(4, this.tile * 0.16), 0xffe14d, 0.95);
this.pathG.beginPath();
this.pathG.moveTo(this.px(unit.x), this.py(unit.y));
for (const s of path) this.pathG.lineTo(this.px(s.x), this.py(s.y));
this.pathG.strokePath();
const last = path[path.length - 1];
const prev = path.length > 1 ? path[path.length - 2] : { x: unit.x, y: unit.y };
const ang = Math.atan2(last.y - prev.y, last.x - prev.x);
const r = this.tile * 0.24;
this.pathG.fillStyle(0xffe14d, 0.95);
this.pathG.beginPath();
this.pathG.moveTo(this.px(last.x) + Math.cos(ang) * r, this.py(last.y) + Math.sin(ang) * r);
this.pathG.lineTo(this.px(last.x) + Math.cos(ang + 2.5) * r, this.py(last.y) + Math.sin(ang + 2.5) * r);
this.pathG.lineTo(this.px(last.x) + Math.cos(ang - 2.5) * r, this.py(last.y) + Math.sin(ang - 2.5) * r);
this.pathG.closePath();
this.pathG.fillPath();
}
setCursor(tile) {
if (!tile) { this.cursor.setVisible(false); return; }
this.cursor.setVisible(true).setPosition(this.px(tile.x), this.py(tile.y));
}
refreshFog(army) {
this.fogG.clear();
if (!this.state.fog) {
this.setUnitVisibility(new Set(this.state.units.map((u) => u.id)));
return;
}
const vis = Logic.computeVision(this.rules, this.state, army);
this.fogG.fillStyle(0x0a0c18, 0.45);
for (let k = 0; k < this.state.terrain.length; k++) {
if (vis.has(k)) continue;
const x = k % this.state.w, y = Math.floor(k / this.state.w);
this.fogG.fillRect(this.ox + x * this.tile, this.oy + y * this.tile, this.tile, this.tile);
}
const spotted = new Set(Logic.visibleUnits(this.rules, this.state, army).map((u) => u.id));
this.setUnitVisibility(spotted);
}
// quick explosion puff at a tile
boom(x, y, onDone) {
const img = this.scene.add.image(this.px(x), this.py(y), this.tex.ui, UI_FRAMES.explosion1)
.setScale(this.spriteScaleFor()).setDepth(this.depths.cursor);
this.scene.time.delayedCall(110, () => img.setFrame(UI_FRAMES.explosion2));
this.scene.time.delayedCall(260, () => { img.destroy(); onDone?.(); });
}
// animate one unit view along a path (state already updated)
animateMove(unitId, path, onDone) {
const v = this.unitViews.get(unitId);
if (!v || !path?.length) { onDone?.(); return; }
const points = path.map((s) => ({ x: this.px(s.x), y: this.oy + (s.y + 1) * this.tile }));
let i = 0;
const step = () => {
if (i >= points.length) { onDone?.(); return; }
const p = points[i++];
this.scene.tweens.add({
targets: v.c, x: p.x, y: p.y, duration: 70, onComplete: step,
});
};
step();
}
}
function darken(color, f) {
const r = Math.floor(((color >> 16) & 0xff) * f);
const g = Math.floor(((color >> 8) & 0xff) * f);
const b = Math.floor((color & 0xff) * f);
return (r << 16) | (g << 8) | b;
}

View File

@ -0,0 +1,98 @@
// Advance Wars rules compiler. No Phaser imports; runs in Node.
// Takes the raw data/advancewars-rules.json and returns an indexed,
// validated rules object shared by the engine, the AI, the scene and
// tools/verifyAdvanceWars.js.
// Every CO day-to-day / power effect key the engine understands. compileRules
// rejects anything else so a data typo fails in the verify script, not
// silently at runtime.
export const EFFECT_KEYS = new Set([
'atk', 'def', 'directAtk', 'indirectAtk', 'footAtk', 'nonFootDirectAtk',
'airAtk', 'seaAtk', 'rangeMod', 'visionMod', 'directMove', 'footMove',
'seaMove', 'transportMove', 'airFuelSave', 'captureMult', 'costMult',
'terrainCostFlat', 'counterFirst', 'refreshNonInfantry', 'healAll',
'meteor', 'snow', 'globalDamage', 'luckMax',
]);
export function compileRules(json) {
if (!json || !Array.isArray(json.units) || !Array.isArray(json.terrains)) {
throw new Error('advancewars-rules: malformed json');
}
const constants = json.constants;
const terrains = json.terrains;
const units = json.units;
const terrainById = {};
const terrainByCh = {};
terrains.forEach((t, i) => {
t.index = i;
if (terrainById[t.id]) throw new Error(`duplicate terrain id ${t.id}`);
if (terrainByCh[t.ch]) throw new Error(`duplicate terrain ch ${t.ch}`);
terrainById[t.id] = t;
terrainByCh[t.ch] = t;
for (const mt of json.moveTypes) {
if (!(mt in t.cost)) throw new Error(`terrain ${t.id} missing cost for ${mt}`);
}
});
const unitById = {};
for (const u of units) {
if (unitById[u.id]) throw new Error(`duplicate unit id ${u.id}`);
if (!json.moveTypes.includes(u.moveType)) throw new Error(`unit ${u.id} bad moveType`);
unitById[u.id] = u;
}
for (const [table, name] of [[json.damage, 'damage'], [json.damageSecondary, 'damageSecondary']]) {
for (const [atk, row] of Object.entries(table)) {
if (!unitById[atk]) throw new Error(`${name}: unknown attacker ${atk}`);
for (const [def, v] of Object.entries(row)) {
if (!unitById[def]) throw new Error(`${name}: unknown defender ${def} (attacker ${atk})`);
if (!Number.isFinite(v) || v <= 0) throw new Error(`${name}: bad value ${atk}->${def}`);
}
}
}
const coById = {};
for (const co of json.cos) {
if (coById[co.id]) throw new Error(`duplicate co id ${co.id}`);
coById[co.id] = co;
for (const key of Object.keys(co.d2d ?? {})) {
if (!EFFECT_KEYS.has(key)) throw new Error(`co ${co.id}: unknown d2d effect ${key}`);
}
if (co.power) {
for (const key of Object.keys(co.power.effects ?? {})) {
if (!EFFECT_KEYS.has(key)) throw new Error(`co ${co.id}: unknown power effect ${key}`);
}
if (!Number.isFinite(co.power.stars) || co.power.stars < 1) {
throw new Error(`co ${co.id}: bad power stars`);
}
}
}
return {
constants,
moveTypes: json.moveTypes,
terrains,
terrainById,
terrainByCh,
units,
unitById,
damage: json.damage,
damageSecondary: json.damageSecondary,
cos: json.cos,
coById,
};
}
// Primary/secondary base damage lookup. Returns { dmg, weapon } or null when
// the attacker cannot hurt the defender at all (given current ammo).
export function baseDamage(rules, attacker, defenderType) {
const spec = rules.unitById[attacker.type];
const primary = rules.damage[attacker.type]?.[defenderType];
// ammo cap 0 means the unit's only weapon needs no ammo (infantry/recon MG)
const hasAmmo = spec.ammo === 0 || attacker.ammo > 0;
if (primary != null && hasAmmo) return { dmg: primary, weapon: spec.ammo === 0 ? 'secondary' : 'primary' };
const secondary = rules.damageSecondary[attacker.type]?.[defenderType];
if (secondary != null) return { dmg: secondary, weapon: 'secondary' };
return null;
}

View File

@ -0,0 +1,299 @@
// Advance Wars full-screen flows: main menu, campaign mission list, mission
// briefings (opponent portrait videos + typewriter text), victory/defeat,
// and the War Room skirmish setup. Each builder returns { destroy() }.
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { createOpponentPortrait } from '../../ui/Portrait.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { FONT, mkText, mkButton } from './AdvanceWarsUI.js';
const PANEL = 0x141826;
const EDGE = 0x3a4260;
const D = 20;
function speakRandom(opponent, mood) {
const pool = opponent?.speech?.[mood];
if (pool?.length) enqueueSpeech(pool[Math.floor(Math.random() * pool.length)]);
}
function screenBase(scene, title) {
const objs = [];
objs.push(scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0b0e18, 1).setDepth(D));
if (title) {
objs.push(mkText(scene, GAME_WIDTH / 2, 90, title, 64, '#ffffff', [0.5, 0.5]).setDepth(D + 1));
}
return objs;
}
// ---------------------------------------------------------------------------
export function mainMenu(scene, { hasSave, onCampaign, onWarRoom, onContinue, onLeave }) {
const objs = screenBase(scene);
objs.push(mkText(scene, GAME_WIDTH / 2, 230, 'ADVANCE WARS', 96, '#ff8c3a', [0.5, 0.5]).setDepth(D + 1));
objs.push(mkText(scene, GAME_WIDTH / 2, 310, 'TURN-BASED TACTICS', 28, '#8f9dc4', [0.5, 0.5]).setDepth(D + 1));
let y = 460;
const add = (label, cb, color) => {
objs.push(mkButton(scene, GAME_WIDTH / 2, y, 420, 74, label, cb, { size: 34, color, depth: D + 1 }));
y += 96;
};
if (hasSave) add('CONTINUE', onContinue, 0x2a5030);
add('CAMPAIGN', onCampaign, 0x2a3350);
add('WAR ROOM', onWarRoom, 0x50352a);
add('LEAVE', onLeave, 0x3a3040);
return { destroy: () => objs.forEach((o) => o.destroy()) };
}
// ---------------------------------------------------------------------------
export function campaignScreen(scene, rules, campaign, completed, oppById, { onPlay, onBack }) {
const objs = screenBase(scene, 'CAMPAIGN');
const missions = campaign.missions;
const cols = missions.length > 12 ? 2 : 1;
const perCol = Math.ceil(missions.length / cols);
const rowH = Math.min(64, Math.floor(780 / perCol));
missions.forEach((m, i) => {
const col = Math.floor(i / perCol);
const cx = cols === 1 ? GAME_WIDTH / 2 : GAME_WIDTH / 2 + (col === 0 ? -400 : 400);
const ry = 190 + (i % perCol) * rowH;
const unlocked = i <= completed;
const done = i < completed;
const row = scene.add.rectangle(cx, ry, 700, rowH - 8, unlocked ? 0x1d2438 : 0x121624, 1)
.setStrokeStyle(2, unlocked ? EDGE : 0x232838).setDepth(D + 1);
objs.push(row);
objs.push(mkText(scene, cx - 330, ry, `${i + 1}.`, 26, unlocked ? '#8f9dc4' : '#3d445c').setDepth(D + 1));
objs.push(mkText(scene, cx - 270, ry, unlocked ? m.name : '— LOCKED —', 26,
done ? '#7dff9a' : unlocked ? '#ffffff' : '#3d445c').setDepth(D + 1));
if (unlocked) {
const enemyCo = rules.coById[m.enemyCos[0]];
objs.push(mkText(scene, cx + 320, ry, `vs ${enemyCo.coName}`, 22, '#ff9d9d', [1, 0.5]).setDepth(D + 1));
row.setInteractive({ useHandCursor: true });
row.on('pointerover', () => row.setFillStyle(0x2a3350, 1));
row.on('pointerout', () => row.setFillStyle(0x1d2438, 1));
row.on('pointerdown', () => onPlay(i));
}
if (done) objs.push(mkText(scene, cx + 340, ry, '✓', 28, '#7dff9a').setDepth(D + 1));
});
objs.push(mkButton(scene, 140, GAME_HEIGHT - 70, 200, 60, 'BACK', onBack, { size: 26, depth: D + 1 }));
return { destroy: () => objs.forEach((o) => o.destroy()) };
}
// ---------------------------------------------------------------------------
// Briefing: lines of { speaker, mood, text } with portrait + typewriter.
export function briefingScreen(scene, rules, mission, oppById, { onDone }) {
const objs = screenBase(scene);
objs.push(mkText(scene, GAME_WIDTH / 2, 110, mission.name.toUpperCase(), 52, '#ff8c3a', [0.5, 0.5]).setDepth(D + 1));
const panel = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT - 260, 1500, 320, PANEL, 0.97)
.setStrokeStyle(3, EDGE).setDepth(D + 1);
objs.push(panel);
const nameT = mkText(scene, GAME_WIDTH / 2 - 500, GAME_HEIGHT - 380, '', 30, '#ffe14d').setDepth(D + 2);
const textT = scene.add.text(GAME_WIDTH / 2 - 500, GAME_HEIGHT - 335, '', {
fontFamily: FONT, fontSize: '30px', color: '#ffffff', wordWrap: { width: 1180 }, lineSpacing: 8,
}).setDepth(D + 2);
objs.push(nameT, textT);
const hint = mkText(scene, GAME_WIDTH / 2 + 690, GAME_HEIGHT - 130, 'TAP ▸', 22, '#8f9dc4', [1, 0.5]).setDepth(D + 2);
objs.push(hint);
let portrait = null;
let li = 0;
let charTimer = null;
let full = '';
let typing = false;
let destroyed = false;
const showLine = () => {
if (destroyed) return;
const line = mission.briefing[li];
if (!line) { finish(); return; }
const opp = oppById[line.speaker];
portrait?.destroy?.();
portrait = createOpponentPortrait(scene, opp, GAME_WIDTH / 2 - 620, GAME_HEIGHT - 260, 110, D + 2, { playIntro: false });
if (line.mood && line.mood !== 'idle') portrait.playEmotion?.(line.mood);
const coEntry = rules.cos.find((c) => c.opponentId === line.speaker);
nameT.setText((coEntry?.coName ?? opp?.name ?? line.speaker).toUpperCase());
full = line.text;
textT.setText('');
typing = true;
let n = 0;
charTimer?.remove();
charTimer = scene.time.addEvent({
delay: 14, repeat: full.length - 1, callback: () => {
n += 1;
textT.setText(full.slice(0, n));
if (n >= full.length) typing = false;
},
});
};
const advance = () => {
if (destroyed) return;
if (typing) {
charTimer?.remove();
textT.setText(full);
typing = false;
return;
}
li += 1;
if (li >= mission.briefing.length) finish();
else showLine();
};
const api = {
destroy: () => {
destroyed = true;
charTimer?.remove();
portrait?.destroy?.();
objs.forEach((o) => o.destroy());
},
};
const finish = () => {
if (destroyed) return;
api.destroy();
onDone();
};
const clickZone = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.001)
.setDepth(D + 1).setInteractive();
clickZone.on('pointerdown', advance);
objs.push(clickZone);
objs.push(mkButton(scene, GAME_WIDTH - 150, 100, 180, 56, 'SKIP', finish, { size: 24, depth: D + 3 }));
if (mission.briefing?.length) showLine();
else scene.time.delayedCall(0, finish);
return api;
}
// ---------------------------------------------------------------------------
export function resultScreen(scene, rules, oppById, { won, mission, days, rank, onContinue, onRetry, onMenu }) {
const objs = screenBase(scene);
objs.push(mkText(scene, GAME_WIDTH / 2, 180, won ? 'VICTORY!' : 'DEFEAT...', 96,
won ? '#7dff9a' : '#ff5a5a', [0.5, 0.5]).setDepth(D + 1));
const line = won ? mission.victoryLine : mission.defeatLine;
const speakerId = line?.speaker ?? (won ? mission.enemyCos?.[0] && rules.coById[mission.enemyCos[0]].opponentId : 'ethel');
const opp = oppById[speakerId];
let portrait = null;
if (opp) {
portrait = createOpponentPortrait(scene, opp, GAME_WIDTH / 2, 420, 130, D + 2, { playIntro: false });
portrait.playEmotion?.('upset');
speakRandom(opp, 'upset');
}
if (line) {
objs.push(scene.add.text(GAME_WIDTH / 2, 640, `"${line.text}"`, {
fontFamily: FONT, fontSize: '28px', color: '#d8ddf0', wordWrap: { width: 1200 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(D + 1));
}
if (won) {
objs.push(mkText(scene, GAME_WIDTH / 2, 800, `Cleared in ${days} day${days === 1 ? '' : 's'} RANK ${rank}`,
34, '#ffe14d', [0.5, 0.5]).setDepth(D + 1));
}
let y = 900;
if (won) {
objs.push(mkButton(scene, GAME_WIDTH / 2, y, 380, 70, 'CONTINUE', onContinue, { size: 30, color: 0x2a5030, depth: D + 1 }));
} else {
objs.push(mkButton(scene, GAME_WIDTH / 2 - 210, y, 360, 70, 'RETRY', onRetry, { size: 30, color: 0x2a3350, depth: D + 1 }));
objs.push(mkButton(scene, GAME_WIDTH / 2 + 210, y, 360, 70, 'MAIN MENU', onMenu, { size: 30, depth: D + 1 }));
}
return {
destroy: () => { portrait?.destroy?.(); objs.forEach((o) => o.destroy()); },
};
}
// ---------------------------------------------------------------------------
// War Room setup: unlocked maps, enemy CO, player CO, fog, difficulty.
export function warRoomScreen(scene, rules, campaign, unlockedCount, oppById, { onStart, onBack }) {
const objs = screenBase(scene, 'WAR ROOM');
const maps = campaign.missions.slice(0, Math.max(1, unlockedCount + 1));
const playableCos = rules.cos.filter((c) => !c.advisorOnly);
const cfg = {
mapIdx: Math.max(0, maps.length - 1),
playerCo: 'andy',
enemyCo: 'olaf',
fog: false,
skill: 3,
};
const rows = [];
const mkPicker = (y, label, getText, onPrev, onNext) => {
objs.push(mkText(scene, GAME_WIDTH / 2 - 420, y, label, 28, '#8f9dc4').setDepth(D + 1));
const val = mkText(scene, GAME_WIDTH / 2 + 130, y, '', 30, '#ffffff', [0.5, 0.5]).setDepth(D + 1);
objs.push(val);
objs.push(mkButton(scene, GAME_WIDTH / 2 - 130, y, 64, 54, '<', () => { onPrev(); refresh(); }, { size: 28, depth: D + 1 }));
objs.push(mkButton(scene, GAME_WIDTH / 2 + 390, y, 64, 54, '>', () => { onNext(); refresh(); }, { size: 28, depth: D + 1 }));
rows.push({ val, getText });
};
const cycle = (list, cur, dir) => list[(list.indexOf(cur) + dir + list.length) % list.length];
mkPicker(260, 'MAP', () => `${maps[cfg.mapIdx].name}`,
() => { cfg.mapIdx = (cfg.mapIdx + maps.length - 1) % maps.length; },
() => { cfg.mapIdx = (cfg.mapIdx + 1) % maps.length; });
mkPicker(360, 'YOUR CO', () => {
const co = rules.coById[cfg.playerCo];
return `${co.coName}`;
},
() => { cfg.playerCo = cycle(playableCos.map((c) => c.id), cfg.playerCo, -1); },
() => { cfg.playerCo = cycle(playableCos.map((c) => c.id), cfg.playerCo, 1); });
mkPicker(460, 'ENEMY CO', () => {
const co = rules.coById[cfg.enemyCo];
return `${co.coName}`;
},
() => { cfg.enemyCo = cycle(playableCos.map((c) => c.id), cfg.enemyCo, -1); },
() => { cfg.enemyCo = cycle(playableCos.map((c) => c.id), cfg.enemyCo, 1); });
mkPicker(560, 'FOG OF WAR', () => (cfg.fog ? 'ON' : 'OFF'),
() => { cfg.fog = !cfg.fog; }, () => { cfg.fog = !cfg.fog; });
mkPicker(660, 'AI LEVEL', () => '★'.repeat(cfg.skill) + '·'.repeat(5 - cfg.skill),
() => { cfg.skill = Math.max(1, cfg.skill - 1); },
() => { cfg.skill = Math.min(5, cfg.skill + 1); });
const coBlurb = mkText(scene, GAME_WIDTH / 2, 750, '', 20, '#9db4e8', [0.5, 0]).setDepth(D + 1);
coBlurb.setWordWrapWidth(900);
objs.push(coBlurb);
const refresh = () => {
for (const r of rows) r.val.setText(r.getText());
const pc = rules.coById[cfg.playerCo];
const ec = rules.coById[cfg.enemyCo];
coBlurb.setText(`${pc.coName}: ${pc.tagline}\n${ec.coName}: ${ec.tagline}`);
};
refresh();
objs.push(mkButton(scene, GAME_WIDTH / 2, 900, 420, 76, 'TO BATTLE!', () => onStart({ ...cfg, mission: maps[cfg.mapIdx] }),
{ size: 32, color: 0x8a4a10, depth: D + 1 }));
objs.push(mkButton(scene, 140, GAME_HEIGHT - 70, 200, 60, 'BACK', onBack, { size: 26, depth: D + 1 }));
return { destroy: () => objs.forEach((o) => o.destroy()) };
}
// ---------------------------------------------------------------------------
// CO power cut-in banner
export function powerCutIn(scene, rules, oppById, co, army, onDone) {
const coDef = rules.coById[co];
const opp = oppById[coDef.opponentId];
const objs = [];
const bar = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, 300, 0x000000, 0.78).setDepth(54);
objs.push(bar);
const name = mkText(scene, GAME_WIDTH / 2 + 60, GAME_HEIGHT / 2 - 40, coDef.power?.name?.toUpperCase() ?? 'CO POWER',
64, army === 0 ? '#7dff9a' : '#ff8c3a', [0.5, 0.5]).setDepth(55).setAlpha(0);
const blurb = mkText(scene, GAME_WIDTH / 2 + 60, GAME_HEIGHT / 2 + 30, coDef.power?.blurb ?? '', 26, '#d8ddf0', [0.5, 0.5]).setDepth(55).setAlpha(0);
objs.push(name, blurb);
let portrait = null;
if (opp) {
portrait = createOpponentPortrait(scene, opp, GAME_WIDTH / 2 - 480, GAME_HEIGHT / 2, 110, 55, { playIntro: false });
portrait.playEmotion?.('happy');
speakRandom(opp, 'happy');
}
scene.tweens.add({ targets: [name, blurb], alpha: 1, duration: 250 });
scene.time.delayedCall(2100, () => {
portrait?.destroy?.();
objs.forEach((o) => o.destroy());
onDone?.();
});
}

View File

@ -0,0 +1,304 @@
// Advance Wars HUD + menus: top strip (funds/day/objective), CO panel with
// power meter, contextual action menu, damage preview, production menu.
// All text in the arcade pixel font.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import * as Logic from './AdvanceWarsLogic.js';
import { UI_FRAMES, armyColorInt } from './AdvanceWarsMapView.js';
import { createOpponentPortrait } from '../../ui/Portrait.js';
export const FONT = 'm6x11, "Julius Sans One"';
const PANEL = 0x141826;
const PANEL_EDGE = 0x3a4260;
export function mkText(scene, x, y, text, size, color = '#ffffff', origin = [0, 0.5]) {
return scene.add.text(x, y, text, {
fontFamily: FONT, fontSize: `${size}px`, color,
}).setOrigin(origin[0], origin[1]);
}
export function mkButton(scene, x, y, w, h, label, onClick, { size = 24, color = 0x2a3350, textColor = '#ffffff', depth = 40 } = {}) {
const c = scene.add.container(x, y).setDepth(depth);
const bg = scene.add.rectangle(0, 0, w, h, color, 1).setStrokeStyle(2, PANEL_EDGE);
const txt = mkText(scene, 0, 0, label, size, textColor, [0.5, 0.5]);
c.add([bg, txt]);
c.setSize(w, h);
c.setInteractive({ useHandCursor: true });
c.on('pointerover', () => bg.setFillStyle(lighten(color), 1));
c.on('pointerout', () => bg.setFillStyle(color, 1));
c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); });
c.bg = bg; c.label = txt;
return c;
}
function lighten(color) {
const r = Math.min(255, ((color >> 16) & 0xff) + 30);
const g = Math.min(255, ((color >> 8) & 0xff) + 30);
const b = Math.min(255, (color & 0xff) + 30);
return (r << 16) | (g << 8) | b;
}
// ---------------------------------------------------------------------------
export class AdvanceWarsHUD {
constructor(scene, rules, { onEndTurn, onMenu, onPower }) {
this.scene = scene;
this.rules = rules;
const d = 30;
this.bar = scene.add.rectangle(GAME_WIDTH / 2, 34, GAME_WIDTH, 68, PANEL, 0.96)
.setStrokeStyle(2, PANEL_EDGE).setDepth(d);
this.dayText = mkText(scene, 28, 34, '', 30).setDepth(d);
this.fundsText = mkText(scene, 190, 34, '', 30, '#ffe14d').setDepth(d);
this.objectiveText = mkText(scene, 470, 34, '', 22, '#9db4e8').setDepth(d);
this.turnText = mkText(scene, GAME_WIDTH / 2 + 210, 34, '', 26, '#ffffff', [0.5, 0.5]).setDepth(d);
this.endTurnBtn = mkButton(scene, GAME_WIDTH - 330, 34, 170, 46, 'END TURN', onEndTurn, { size: 24 });
this.menuBtn = mkButton(scene, GAME_WIDTH - 120, 34, 130, 46, 'MENU', onMenu, { size: 24 });
// CO panel (right column)
const px = GAME_WIDTH - 148;
this.coPanel = scene.add.rectangle(px, 620, 268, 1080 - 90, PANEL, 0.92)
.setStrokeStyle(2, PANEL_EDGE).setDepth(d - 1);
this.coName = mkText(scene, px, 210, '', 28, '#ffffff', [0.5, 0.5]).setDepth(d);
this.coTag = mkText(scene, px, 244, '', 16, '#8f9dc4', [0.5, 0]).setDepth(d);
this.coTag.setWordWrapWidth(240);
this.powerLabel = mkText(scene, px, 330, '', 20, '#ffd94d', [0.5, 0.5]).setDepth(d);
this.stars = [];
this.powerBtn = mkButton(scene, px, 395, 220, 52, 'POWER!', onPower, { size: 26, color: 0x8a4a10 });
this.powerBtn.setVisible(false);
this.enemyName = mkText(scene, px, 640, '', 24, '#ff9d9d', [0.5, 0.5]).setDepth(d);
this.enemyPowerLabel = mkText(scene, px, 810, '', 18, '#c99', [0.5, 0.5]).setDepth(d);
this.enemyStars = [];
this.portraits = [];
}
attachPortraits(playerOpp, enemyOpp) {
const px = GAME_WIDTH - 148;
for (const p of this.portraits) p?.destroy?.();
this.portraits = [
createOpponentPortrait(this.scene, playerOpp, px, 140, 56, 31, { playIntro: false }),
enemyOpp ? createOpponentPortrait(this.scene, enemyOpp, px, 720, 56, 31, { playIntro: false }) : null,
];
}
buildStars(rules, state) {
for (const s of [...this.stars, ...this.enemyStars]) s.destroy();
this.stars = []; this.enemyStars = [];
const px = GAME_WIDTH - 148;
const mk = (co, y, arr) => {
const n = rules.coById[co].power?.stars ?? 0;
const total = n * 24;
for (let i = 0; i < n; i++) {
const img = this.scene.add.image(px - total / 2 + 12 + i * 24, y, this.uiKey ?? 'advancewars-ui-proc', UI_FRAMES.star)
.setDisplaySize(22, 22).setDepth(31);
arr.push(img);
}
};
mk(state.armies[0].co, 292, this.stars);
if (state.armies[1]) mk(state.armies[1].co, 775, this.enemyStars);
}
refresh(state, humanArmy = 0) {
const rules = this.rules;
const a = state.armies[humanArmy];
const co = rules.coById[a.co];
this.dayText.setText(`DAY ${state.day}`);
this.fundsText.setText(`G ${a.funds.toLocaleString()}`);
this.turnText.setText(state.turn === humanArmy ? 'YOUR TURN' : `${rules.coById[state.armies[state.turn].co].coName}'S TURN`);
this.turnText.setColor(state.turn === humanArmy ? '#7dff9a' : '#ff9d9d');
this.coName.setText(co.coName);
this.coTag.setText(co.tagline ?? '');
const powered = a.powerActive;
this.powerLabel.setText(powered ? `${co.power?.name ?? ''} ACTIVE!` : (co.power?.name ?? ''));
const setStars = (army, arr) => {
const st = state.armies[army];
const def = rules.coById[st.co];
if (!def.power) return;
const per = rules.constants.starCharge;
arr.forEach((img, i) => {
const filled = st.charge >= (i + 1) * per;
img.setAlpha(filled ? 1 : 0.25);
});
};
setStars(humanArmy, this.stars);
if (state.armies[1]) {
const eco = rules.coById[state.armies[1].co];
this.enemyName.setText(eco.coName);
this.enemyPowerLabel.setText(eco.power?.name ?? '');
setStars(1, this.enemyStars);
}
this.powerBtn.setVisible(state.turn === humanArmy && Logic.powerReady(rules, state, humanArmy));
const myTurn = state.turn === humanArmy && !state.result;
this.endTurnBtn.setAlpha(myTurn ? 1 : 0.4);
}
setObjective(text) { this.objectiveText.setText(text); }
destroy() {
for (const p of this.portraits) p?.destroy?.();
for (const s of [...this.stars, ...this.enemyStars]) s.destroy();
for (const o of [this.bar, this.dayText, this.fundsText, this.objectiveText, this.turnText,
this.endTurnBtn, this.menuBtn, this.coPanel, this.coName, this.coTag, this.powerLabel,
this.powerBtn, this.enemyName, this.enemyPowerLabel]) o?.destroy();
}
}
// ---------------------------------------------------------------------------
// Contextual action menu — big touch-friendly vertical buttons.
export class ActionMenu {
constructor(scene) {
this.scene = scene;
this.items = [];
}
open(x, y, options) {
this.close();
const w = 210, h = 52, gap = 6;
const total = options.length * (h + gap);
let top = Math.min(Math.max(y - total / 2, 90), GAME_HEIGHT - total - 20);
const left = Math.min(x + 30, GAME_WIDTH - 300 - w);
options.forEach((opt, i) => {
const btn = mkButton(this.scene, left + w / 2, top + i * (h + gap) + h / 2, w, h,
opt.label, () => { this.close(); opt.cb(); },
{ size: 24, color: opt.danger ? 0x6b2a2a : 0x2a3350, depth: 45 });
if (opt.disabled) { btn.setAlpha(0.45); btn.disableInteractive(); }
this.items.push(btn);
});
}
close() {
for (const b of this.items) b.destroy();
this.items = [];
}
get isOpen() { return this.items.length > 0; }
}
// ---------------------------------------------------------------------------
// Damage preview panel
export class DamagePreview {
constructor(scene) {
this.scene = scene;
this.objs = [];
}
show(x, y, dealPct, counterPct, onConfirm, onCancel) {
this.hide();
const w = 300, h = 170;
const px = Math.min(Math.max(x, w / 2 + 20), GAME_WIDTH - 300 - w / 2);
const py = Math.min(Math.max(y - 140, 100), GAME_HEIGHT - h - 20);
const panel = this.scene.add.rectangle(px, py + h / 2, w, h, PANEL, 0.97)
.setStrokeStyle(2, PANEL_EDGE).setDepth(46);
const t1 = mkText(this.scene, px, py + 34, `DAMAGE ${dealPct}%`, 28, '#7dff9a', [0.5, 0.5]).setDepth(46);
const t2 = mkText(this.scene, px, py + 70, counterPct == null ? 'NO COUNTER' : `COUNTER ${counterPct}%`,
22, counterPct == null ? '#8f9dc4' : '#ff9d9d', [0.5, 0.5]).setDepth(46);
const ok = mkButton(this.scene, px - 70, py + 126, 120, 48, 'FIRE!', () => { this.hide(); onConfirm(); },
{ size: 24, color: 0x8a2a2a, depth: 46 });
const no = mkButton(this.scene, px + 70, py + 126, 120, 48, 'BACK', () => { this.hide(); onCancel(); },
{ size: 24, depth: 46 });
this.objs = [panel, t1, t2, ok, no];
}
hide() { for (const o of this.objs) o.destroy(); this.objs = []; }
get isOpen() { return this.objs.length > 0; }
}
// ---------------------------------------------------------------------------
// Production menu (modal)
export class ProductionMenu {
constructor(scene, rules, unitsKey) {
this.scene = scene;
this.rules = rules;
this.unitsKey = unitsKey;
this.objs = [];
}
open(options, funds, onPick, onClose) {
this.close();
const rows = options.length;
const w = 460, rowH = 58, h = rows * rowH + 110;
const cx = GAME_WIDTH / 2 - 130, cy = GAME_HEIGHT / 2;
const dim = this.scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45)
.setDepth(44).setInteractive();
dim.on('pointerdown', () => { this.close(); onClose?.(); });
const panel = this.scene.add.rectangle(cx, cy, w, h, PANEL, 0.97)
.setStrokeStyle(2, PANEL_EDGE).setDepth(45);
const title = mkText(this.scene, cx, cy - h / 2 + 34, 'BUILD UNIT', 30, '#ffffff', [0.5, 0.5]).setDepth(45);
const fundsT = mkText(this.scene, cx, cy - h / 2 + 66, `Funds: G ${funds.toLocaleString()}`, 20, '#ffe14d', [0.5, 0.5]).setDepth(45);
this.objs = [dim, panel, title, fundsT];
options.forEach((opt, i) => {
const spec = this.rules.unitById[opt.type];
const ry = cy - h / 2 + 100 + i * rowH + rowH / 2;
const row = this.scene.add.rectangle(cx, ry, w - 30, rowH - 8, opt.affordable ? 0x223050 : 0x1a1f30, 1)
.setStrokeStyle(1, PANEL_EDGE).setDepth(45);
const icon = this.scene.add.image(cx - w / 2 + 50, ry + 12, this.unitsKey, spec.frame)
.setOrigin(0.5, 0.78).setScale(0.8).setDepth(45);
const name = mkText(this.scene, cx - w / 2 + 95, ry, spec.name, 24,
opt.affordable ? '#ffffff' : '#666e88').setDepth(45);
const cost = mkText(this.scene, cx + w / 2 - 40, ry, `G ${opt.cost.toLocaleString()}`, 22,
opt.affordable ? '#ffe14d' : '#666e88', [1, 0.5]).setDepth(45);
this.objs.push(row, icon, name, cost);
if (opt.affordable) {
row.setInteractive({ useHandCursor: true });
row.on('pointerover', () => row.setFillStyle(0x2e4070, 1));
row.on('pointerout', () => row.setFillStyle(0x223050, 1));
row.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); this.close(); onPick(opt.type); });
}
});
}
close() { for (const o of this.objs) o.destroy(); this.objs = []; }
get isOpen() { return this.objs.length > 0; }
}
// ---------------------------------------------------------------------------
// Tile info chip (bottom-left): terrain name, stars, capture progress.
export class TileInfo {
constructor(scene, rules) {
this.scene = scene;
this.rules = rules;
const d = 30;
this.panel = scene.add.rectangle(150, GAME_HEIGHT - 60, 280, 96, PANEL, 0.92)
.setStrokeStyle(2, PANEL_EDGE).setDepth(d);
this.name = mkText(scene, 36, GAME_HEIGHT - 84, '', 24).setDepth(d);
this.detail = mkText(scene, 36, GAME_HEIGHT - 50, '', 18, '#9db4e8').setDepth(d);
this.unitLine = mkText(scene, 36, GAME_HEIGHT - 26, '', 18, '#ffe14d').setDepth(d);
}
show(state, x, y, spottedUnit) {
const t = Logic.terrainAt(this.rules, state, x, y);
const k = Logic.tileKey(state, x, y);
let name = t.name;
if (t.property) {
const owner = state.owner[k];
name += owner < 0 ? ' (Neutral)' : ` (${this.rules.constants.armyNames[owner] ?? 'Army'})`;
}
this.name.setText(name);
let detail = `Defense ${'★'.repeat(t.stars) || '—'}`;
if (t.property && state.captureHp[k] < this.rules.constants.captureGoal) {
detail += ` Capture ${state.captureHp[k]}/${this.rules.constants.captureGoal}`;
}
this.detail.setText(detail);
if (spottedUnit) {
const spec = this.rules.unitById[spottedUnit.type];
const bits = [`${spec.name} ${Logic.hpDisplay(spottedUnit)}/10`];
bits.push(`F${spottedUnit.fuel}`);
if (spec.ammo > 0) bits.push(`A${spottedUnit.ammo}`);
this.unitLine.setText(bits.join(' '));
} else {
this.unitLine.setText('');
}
}
destroy() { for (const o of [this.panel, this.name, this.detail, this.unitLine]) o.destroy(); }
}

View File

@ -0,0 +1,115 @@
# Advance Wars — spritesheet spec
Art is split into **four independent sheets**, one per category, so each can
be painted and dropped in separately. Until a sheet is painted, the game
renders a procedural stand-in with the exact same frame map — everything is
fully playable art-free, and you can drop in one sheet at a time.
**Rivers and roads need NO art**: they are drawn in code (Graphics) from
tile connection masks, so there are no variant frames to paint. Shoreline
foam where sea meets land is also drawn in code.
## Drop-in procedure (no code changes)
1. Paint a sheet per the specs below.
2. Save it under `assets/images/advancewars/` (suggested names below).
3. In `data/advancewars-artwork.json`, set that sheet's `path`.
4. Reload. Sheets left `path: null` keep their procedural stand-in.
Keep source PSDs next to the PNGs. **Frames are append-only — never
renumber.** Frame indices are pinned in `data/advancewars-rules.json`
(`terrains[].frame`, `units[].frame`) and in `UI_FRAMES` in
`AdvanceWarsMapView.js`.
## Tinting rules (important!)
The **building sheet** and the **unit sheet** are painted **neutral and
tintable**: light gray / near-white bodies (#e0e0e0-ish) with dark outlines.
The engine applies each army's color with a multiply tint at runtime
(orange / blue / yellow / green + gray for neutral buildings), and **flips
unit art horizontally** for enemy armies — so paint every unit **facing
RIGHT**. Avoid saturated colors on anything tinted; details that must stay
colored (glass, smoke, tires) should be dark.
The terrain and UI sheets are painted in full color — no tinting.
---
## 1. Terrain sheet — `advancewars-terrain.png`
**48×48 cells, 8 columns × 1 row = 384×48 px.** Full color, opaque tiles.
| frame | content |
|-------|---------|
| 0 | Plain (grass) |
| 1 | Wood (trees — hides units in fog) |
| 2 | Mountain |
| 3 | Sea |
| 4 | Reef (rocky shallows on sea) |
| 5 | Shoal (beach/sand transition) |
| 6 | Bridge, horizontal (paint the water background into the tile) |
| 7 | Bridge, vertical (ditto) |
Everything is a flat 48×48 tile — no headroom on this sheet. Rivers, roads
and coastlines are code-drawn; do not paint them.
## 2. Building sheet — `advancewars-buildings.png`
**48×64 cells, 5 frames in 1 row (suggest 6 columns = 288×64 px, last cell
spare).** Bottom 48×48 is the tile footprint; the top 16 px is headroom for
roofs and flags. Bottom-aligned, transparent background (the ground tile
shows through), **neutral + tintable**.
| frame | content |
|-------|---------|
| 0 | City |
| 1 | Base (land factory) |
| 2 | Airport |
| 3 | Port |
| 4 | HQ (make it grand — flag, use the headroom) |
## 3. Unit sheet — `advancewars-units.png`
**48×64 cells, 12 columns × 3 rows = 576×192 px.** Bottom-aligned in the
48×48 footprint with 16 px headroom for heads/rotors/masts. Transparent
background, **neutral + tintable, facing RIGHT**.
Each unit gets exactly **two frames**: `[idle A, idle B]` — a subtle
2-frame cycle (bob, tread shift, rotor spin). Frames are `base` and
`base+1`, laid out row-major:
| base | unit | | base | unit |
|------|------|-|------|------|
| 0 | Infantry | | 18 | Missiles |
| 2 | Mech | | 20 | Fighter |
| 4 | Recon | | 22 | Bomber |
| 6 | Tank | | 24 | B-Copter |
| 8 | Md Tank | | 26 | T-Copter |
| 10 | APC | | 28 | Battleship |
| 12 | Artillery | | 30 | Cruiser |
| 14 | Rockets | | 32 | Lander |
| 16 | Anti-Air | | 34 | Sub |
## 4. UI sheet — `advancewars-ui.png`
**48×48 cells, 7 frames in 1 row (suggest 8 columns = 384×48 px, last cell
spare).** Full color, transparent background.
| frame | content |
|-------|---------|
| 0 | tile cursor (corner brackets, white) |
| 1 | explosion, small |
| 2 | explosion, big |
| 3 | capture flag icon |
| 4 | low-fuel icon (lightning bolt) |
| 5 | low-ammo icon (bullet) |
| 6 | CO power star |
Icons 36 render at roughly 40% tile size as unit badges; keep them bold
and readable when small.
## Game icon
Also paint **frame 86 of `assets/images/game-icons.png`** (44×44, row 5
col 11 of that 15-per-row sheet). Suggested motif: a small orange tank on a
green tile, AW box-art style.

View File

@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { api } from '../../services/api.js';
import {
@ -53,7 +54,10 @@ export default class SpireClimbGame extends Phaser.Scene {
}
create() {
try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) {}
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) new MusicPlayer(this, tracks, volume);
} catch (_) {}
// art config
this.art = this.cache.json.get('spireclimb-artwork') || {};

View File

@ -14,6 +14,7 @@ import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, playForceMove, SFX } from '../../ui/Sounds.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { api } from '../../services/api.js';
import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js';
@ -179,7 +180,10 @@ export default class SWDBGGame extends Phaser.Scene {
}
create() {
try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ }
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) new MusicPlayer(this, tracks, volume);
} catch (_) {}
this.art = this.cache.json.get('swdbg-artwork') || {};
loadCardData(this.cache.json.get('swdbg-cards'));

View File

@ -95,6 +95,7 @@ import CivilizationGame from './games/civilization/CivilizationGame.js';
import TempestGame from './games/tempest/TempestGame.js';
import SuperKartGame from './games/superkart/SuperKartGame.js';
import SuperKartEditor from './games/superkart/SuperKartEditor.js';
import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js';
const config = {
type: Phaser.AUTO,
@ -203,6 +204,7 @@ const config = {
TempestGame,
SuperKartGame,
SuperKartEditor,
AdvanceWarsGame,
],
};

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -46,6 +46,8 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('nintendo-music', 'data/nintendo-music.json');
this.load.json('arcadedark-music', 'data/arcadedark-music.json');
this.load.json('hacker-music', 'data/hacker-music.json');
this.load.json('adventure-music', 'data/adventure-music.json');
this.load.json('advancewars-artwork', 'data/advancewars-artwork.json');
this.load.json('rushhour', 'data/rushhour.json');
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
this.load.json('shift-artwork', 'data/shift-artwork.json');

View File

@ -18,6 +18,7 @@
// if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
export const GAME_SOUNDTRACK_OVERRIDES = {
superkart: 'nintendo',
advancewars: 'nintendo',
coloradodefense: 'arcadedark',
tempest: 'arcadedark',
mastermind: 'hacker',
@ -25,6 +26,8 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
hexsweeper: 'hacker',
dotlink: 'hacker',
'2048': 'hacker',
swdbg: 'adventure',
spireclimb: 'adventure'
};
// Resolve the track list (and optional volume override) a game scene's

827
tools/verifyAdvanceWars.js Normal file
View File

@ -0,0 +1,827 @@
// Headless verification for Advance Wars.
// node tools/verifyAdvanceWars.js [--quick]
// Exits non-zero on any failure.
//
// 1. Rules integrity (18 units, frames, damage tables, CO effect keys,
// opponent ids resolve against data/opponents.json).
// 2. Canonical AW1 damage-chart fixtures (guards chart transcription).
// 3. Combat formula fixtures (terrain stars, HP scaling, CO modifiers, luck
// bounds, counterattack ordering, Sonja counter-first).
// 4. Movement / pathfinding (terrain costs, blockers, fuel cap, fog traps).
// 5. Economy (income, repairs, production, Kanbei costs, APC resupply,
// fuel crashes).
// 6. Capture (progress, interruption, Sami multiplier, HQ elimination).
// 7. Fog of war (vision ranges, wood/reef hiding, dived subs).
// 8. CO powers (charge accrual, every power's effect fires and expires).
// 9. Serialization round-trip.
// 10. Campaign data validation (maps decode, speakers/COs resolve).
// 11. AI behaviour + full-game soak (added with AdvanceWarsAI).
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { compileRules, EFFECT_KEYS, baseDamage } from '../src/games/advancewars/AdvanceWarsRules.js';
import * as Logic from '../src/games/advancewars/AdvanceWarsLogic.js';
import { runAITurn } from '../src/games/advancewars/AdvanceWarsAI.js';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUICK = process.argv.includes('--quick');
const rulesJson = JSON.parse(readFileSync(join(root, 'data/advancewars-rules.json'), 'utf8'));
const campaign = JSON.parse(readFileSync(join(root, 'data/advancewars-campaign.json'), 'utf8'));
const opponents = JSON.parse(readFileSync(join(root, 'data/opponents.json'), 'utf8')).opponents;
let failures = 0;
let checks = 0;
function check(name, cond, detail = '') {
checks += 1;
if (cond) return;
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
const rules = compileRules(rulesJson);
// Tiny map builder for fixtures. Rows of terrain chars; units placed after.
function mkGame(tiles, units, opts = {}) {
const mapDef = {
w: tiles[0].length, h: tiles.length, tiles,
properties: opts.properties ?? [],
units,
};
return Logic.createGame(rules, mapDef, { cos: ['andy', 'olaf'], seed: 7, ...opts });
}
function findUnit(state, army, type) {
return state.units.find((u) => u.army === army && u.type === type);
}
// ── 1. Rules integrity ───────────────────────────────────────────────────────
console.log('Rules integrity');
{
check('18 unit types', rules.units.length === 18, `got ${rules.units.length}`);
// frames index per-category sheets (terrain 48x48, buildings 48x64,
// units 48x64 pairs, ui 48x48); rivers/roads are Graphics-drawn — no frame
const frames = new Set();
for (const u of rules.units) {
check(`${u.id}: frame pair in unit sheet`, u.frame >= 0 && u.frame % 2 === 0 && u.frame < 36);
check(`${u.id}: frame pair unique`, !frames.has(u.frame));
frames.add(u.frame); frames.add(u.frame + 1);
}
for (const t of rules.terrains) {
if (t.id === 'river' || t.id === 'road') {
check(`${t.id}: no sprite frame (Graphics-drawn)`, t.frame === undefined);
} else if (t.property) {
check(`${t.id}: frame in building sheet`, t.frame >= 0 && t.frame < 5);
} else {
check(`${t.id}: frame in terrain sheet`, t.frame >= 0 && t.frame < 8);
}
}
// every combat unit can hurt something; every unit can be hurt
for (const u of rules.units) {
const canHit = Object.keys(rules.damage[u.id] ?? {}).length +
Object.keys(rules.damageSecondary[u.id] ?? {}).length;
if (u.range) check(`${u.id}: can damage something`, canHit > 0);
else check(`${u.id}: transports have no attack`, canHit === 0);
const hittable = Object.values(rules.damage).some((row) => row[u.id] != null) ||
Object.values(rules.damageSecondary).some((row) => row[u.id] != null);
check(`${u.id}: damageable`, hittable);
}
const oppIds = new Set(opponents.map((o) => o.id));
const excluded = new Set(['croc', 'smasher', 'kona', 'bernie', 'mario', 'fireball',
'zanthor', 'blackwind', 'dv-8-2303', 'gerome']);
for (const co of rules.cos) {
check(`CO ${co.id}: opponent ${co.opponentId} exists`, oppIds.has(co.opponentId));
check(`CO ${co.id}: opponent not excluded`, !excluded.has(co.opponentId));
}
check('11 COs', rules.cos.length === 11, `got ${rules.cos.length}`);
check('effect keys closed set holds', (() => {
try { compileRules({ ...rulesJson, cos: [{ id: 'x', d2d: { bogus: 1 } }] }); return false; }
catch { return true; }
})());
}
// ── 2. Damage chart fixtures ─────────────────────────────────────────────────
console.log('Damage chart fixtures');
{
const fixtures = [
['infantry', 'infantry', 55], ['infantry', 'tcopter', 30],
['mech', 'tank', 55], ['mech', 'recon', 85],
['recon', 'infantry', 70],
['tank', 'recon', 85], ['tank', 'tank', 55], ['tank', 'mdtank', 15],
['mdtank', 'tank', 85], ['mdtank', 'mdtank', 55],
['artillery', 'tank', 70], ['artillery', 'infantry', 90],
['rockets', 'infantry', 95], ['rockets', 'mdtank', 55],
['antiair', 'infantry', 105], ['antiair', 'bcopter', 120], ['antiair', 'bomber', 75],
['missiles', 'fighter', 100], ['missiles', 'bcopter', 120],
['fighter', 'bomber', 100], ['fighter', 'fighter', 55],
['bomber', 'mdtank', 95], ['bomber', 'battleship', 75], ['bomber', 'infantry', 110],
['bcopter', 'tank', 55], ['bcopter', 'antiair', 25],
['battleship', 'cruiser', 95], ['battleship', 'battleship', 50],
['cruiser', 'sub', 90],
['sub', 'battleship', 55], ['sub', 'lander', 95], ['sub', 'cruiser', 25],
];
for (const [atk, def, want] of fixtures) {
check(`${atk} vs ${def} = ${want}`, rulesJson.damage[atk]?.[def] === want,
`got ${rulesJson.damage[atk]?.[def]}`);
}
const secondary = [
['mech', 'infantry', 65], ['tank', 'infantry', 75], ['mdtank', 'infantry', 105],
['bcopter', 'infantry', 75], ['cruiser', 'bcopter', 115],
];
for (const [atk, def, want] of secondary) {
check(`${atk} MG vs ${def} = ${want}`, rulesJson.damageSecondary[atk]?.[def] === want,
`got ${rulesJson.damageSecondary[atk]?.[def]}`);
}
}
// ── 3. Combat formula ────────────────────────────────────────────────────────
console.log('Combat formula');
{
const state = mkGame([
'......',
'......',
'......',
], [
{ army: 0, type: 'tank', x: 1, y: 1 },
{ army: 1, type: 'recon', x: 2, y: 1 },
]);
const tank = findUnit(state, 0, 'tank');
const recon = findUnit(state, 1, 'recon');
check('tank vs recon, plain, full HP = 76',
Logic.computeDamage(rules, state, tank, recon, 0).dmg === 76,
`got ${Logic.computeDamage(rules, state, tank, recon, 0).dmg}`);
check('luck 9 raises it to 84',
Logic.computeDamage(rules, state, tank, recon, 9).dmg === 84,
`got ${Logic.computeDamage(rules, state, tank, recon, 9).dmg}`);
tank.hp = 50;
check('half-HP attacker scales: 38',
Logic.computeDamage(rules, state, tank, recon, 0).dmg === 38,
`got ${Logic.computeDamage(rules, state, tank, recon, 0).dmg}`);
tank.hp = 100;
// terrain stars
const cityState = mkGame([
'.c....',
], [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'recon', x: 1, y: 0 },
]);
const t2 = findUnit(cityState, 0, 'tank');
const r2 = findUnit(cityState, 1, 'recon');
check('tank vs recon on 3-star city = 59',
Logic.computeDamage(rules, cityState, t2, r2, 0).dmg === 59,
`got ${Logic.computeDamage(rules, cityState, t2, r2, 0).dmg}`);
// air units get no terrain cover
const airState = mkGame([
'w.....',
], [
{ army: 1, type: 'bcopter', x: 0, y: 0 },
{ army: 0, type: 'antiair', x: 1, y: 0 },
]);
const aa = findUnit(airState, 0, 'antiair');
const bc = findUnit(airState, 1, 'bcopter');
check('AA vs bcopter over wood ignores stars = 120',
Logic.computeDamage(rules, airState, aa, bc, 0).dmg === 120,
`got ${Logic.computeDamage(rules, airState, aa, bc, 0).dmg}`);
// CO modifiers
const kanbeiState = Logic.createGame(rules, {
w: 6, h: 1, tiles: ['......'],
units: [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 1, y: 0 },
],
}, { cos: ['kanbei', 'andy'], seed: 1 });
const kInf = findUnit(kanbeiState, 0, 'infantry');
const aInf = findUnit(kanbeiState, 1, 'infantry');
check('Kanbei inf vs inf = 64 (130% atk)',
Logic.computeDamage(rules, kanbeiState, kInf, aInf, 0).dmg === 64,
`got ${Logic.computeDamage(rules, kanbeiState, kInf, aInf, 0).dmg}`);
// Kanbei defense: attacker 100%, defender 130% → (200-130-10)/100 = 0.6
check('inf vs Kanbei inf = 33 (130% def)',
Logic.computeDamage(rules, kanbeiState, aInf, kInf, 0).dmg === 33,
`got ${Logic.computeDamage(rules, kanbeiState, aInf, kInf, 0).dmg}`);
const maxState = Logic.createGame(rules, {
w: 6, h: 1, tiles: ['......'],
units: [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 0, type: 'artillery', x: 2, y: 0 },
{ army: 1, type: 'recon', x: 1, y: 0 },
],
}, { cos: ['max', 'andy'], seed: 1 });
const mTank = findUnit(maxState, 0, 'tank');
const mArty = findUnit(maxState, 0, 'artillery');
const mRecon = findUnit(maxState, 1, 'recon');
check('Max tank vs recon = 91 (120% direct)',
Logic.computeDamage(rules, maxState, mTank, mRecon, 0).dmg === 91,
`got ${Logic.computeDamage(rules, maxState, mTank, mRecon, 0).dmg}`);
check('Max artillery is weakened (90%)',
Logic.computeDamage(rules, maxState, mArty, mRecon, 0).dmg === Math.floor(80 * 0.9 * 0.9),
`got ${Logic.computeDamage(rules, maxState, mArty, mRecon, 0).dmg}`);
check('Max indirect range shrinks to 2', Logic.effectiveRange(rules, maxState, mArty)[1] === 2);
// counterattack: attack resolves both volleys, indirect never counters
const counterState = mkGame([
'......',
], [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 1, y: 0 },
{ army: 1, type: 'artillery', x: 2, y: 0 },
]);
const cTank = findUnit(counterState, 0, 'tank');
const eTank = findUnit(counterState, 1, 'tank');
const res = Logic.applyAction(counterState, rules, { type: 'attack', unitId: cTank.id, path: [], targetId: eTank.id });
check('attack succeeds', res.ok, res.error);
const battles = res.events.filter((e) => e.type === 'battle');
check('defender counters (2 volleys)', battles.length === 2, `got ${battles.length}`);
check('attacker took counter damage', cTank.hp < 100);
const eArty = findUnit(counterState, 1, 'artillery');
if (eArty && cTank.hp > 0) {
const before = cTank.hp;
// move next to artillery and hit it: no counter volley from an indirect
const res2 = Logic.applyAction(counterState, rules, {
type: 'attack', unitId: findUnit(counterState, 0, 'tank')?.id ?? cTank.id,
path: [], targetId: eArty.id,
});
if (res2.ok) {
const b2 = res2.events.filter((e) => e.type === 'battle');
check('indirect never counters', b2.length === 1, `got ${b2.length}`);
check('no counter damage taken', cTank.hp === before);
}
}
// Sonja Counter Break: defender strikes first while power active
const sonjaState = Logic.createGame(rules, {
w: 4, h: 1, tiles: ['....'],
units: [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 1, y: 0 },
],
}, { cos: ['andy', 'sonja'], seed: 1 });
sonjaState.armies[1].powerActive = true;
const sAtk = findUnit(sonjaState, 0, 'tank');
const sDef = findUnit(sonjaState, 1, 'tank');
const sres = Logic.applyAction(sonjaState, rules, { type: 'attack', unitId: sAtk.id, path: [], targetId: sDef.id });
const sb = sres.events.filter((e) => e.type === 'battle');
check('Counter Break: defender fires first', sb.length === 2 && sb[0].attackerId === sDef.id);
}
// ── 4. Movement / pathfinding ────────────────────────────────────────────────
console.log('Movement');
{
const state = mkGame([
'......',
'.m.w..',
'......',
], [
{ army: 0, type: 'infantry', x: 0, y: 1 },
{ army: 0, type: 'recon', x: 0, y: 0 },
{ army: 0, type: 'tank', x: 0, y: 2 },
{ army: 1, type: 'infantry', x: 4, y: 1 },
]);
const inf = findUnit(state, 0, 'infantry');
const reach = Logic.reachableTiles(rules, state, inf);
check('infantry move 3 reaches (2,1)', reach.dist.get(Logic.tileKey(state, 2, 1)) === 3);
check('mountain costs 2 for foot', reach.dist.get(Logic.tileKey(state, 1, 1)) === 2);
const recon = findUnit(state, 0, 'recon');
const rReach = Logic.reachableTiles(rules, state, recon);
check('tires cannot enter mountain', !rReach.dist.has(Logic.tileKey(state, 1, 1)));
check('plain costs 2 for tires', rReach.dist.get(Logic.tileKey(state, 1, 0)) === 2);
const tank = findUnit(state, 0, 'tank');
const tReach = Logic.reachableTiles(rules, state, tank);
check('wood costs 2 for tread', tReach.dist.get(Logic.tileKey(state, 3, 1)) ===
(tReach.dist.get(Logic.tileKey(state, 3, 2)) ?? 99) - 1 + 2 - 1 ||
tReach.dist.get(Logic.tileKey(state, 3, 1)) >= 3);
// enemy blocks
check('enemy tile unreachable', !reach.dist.has(Logic.tileKey(state, 4, 1)) ||
reach.dist.get(Logic.tileKey(state, 4, 1)) === undefined);
// fuel caps movement
inf.fuel = 1;
const fuelReach = Logic.reachableTiles(rules, state, inf);
const maxDist = Math.max(...[...fuelReach.dist.values()]);
check('fuel caps movement', maxDist <= 1, `got ${maxDist}`);
inf.fuel = 99;
// cannot stop on a friendly unit
const stopRes = Logic.applyAction(state, rules, {
type: 'wait', unitId: inf.id,
path: [{ x: 0, y: 0 }],
});
check('cannot stop on occupied tile', !stopRes.ok);
// moving spends fuel
const before = inf.fuel;
const mv = Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 0, y: 2 }, { x: 1, y: 2 }] });
check('moving spends fuel', mv.ok && inf.fuel === before - 2, `ok=${mv.ok} fuel=${inf.fuel}`);
}
// ── 5. Economy / production ──────────────────────────────────────────────────
console.log('Economy');
{
const state = mkGame([
'qcf...',
'......',
], [
{ army: 0, type: 'infantry', x: 3, y: 0, hp: 40 },
{ army: 1, type: 'infantry', x: 5, y: 1 },
], {
properties: [
{ x: 0, y: 0, owner: 0 }, { x: 1, y: 0, owner: 0 }, { x: 2, y: 0, owner: 0 },
],
startFunds: [0, 0],
});
// opening dayStart already ran in createGame
check('day-1 income: 3 properties = 3000', state.armies[0].funds === 3000,
`got ${state.armies[0].funds}`);
// build a unit
const buildRes = Logic.applyAction(state, rules, { type: 'build', x: 2, y: 0, unitType: 'infantry' });
check('build works', buildRes.ok, buildRes.error);
check('build deducts funds', state.armies[0].funds === 2000);
const built = Logic.unitAt(state, 2, 0);
check('built unit cannot act', built.moved === true);
const buildRes2 = Logic.applyAction(state, rules, { type: 'build', x: 2, y: 0, unitType: 'tank' });
check('occupied factory refuses', !buildRes2.ok);
// repair: put damaged infantry on the city, cycle a day (passes through
// the friendly just built on the base)
const dmgInf = findUnit(state, 0, 'infantry');
const walk = Logic.applyAction(state, rules, { type: 'wait', unitId: dmgInf.id, path: [{ x: 2, y: 0 }, { x: 1, y: 0 }] });
check('walk onto city ok', walk.ok, walk.error);
const fundsBefore = state.armies[0].funds;
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'endTurn' });
check('repair heals 20 on friendly city', dmgInf.hp === 60, `got ${dmgInf.hp}`);
check('repair costs funds (200 for 20% of 1000 + income 3000)',
state.armies[0].funds === fundsBefore + 3000 - 200,
`got ${state.armies[0].funds}, expected ${fundsBefore + 3000 - 200}`);
// Kanbei pays 120%
const kState = Logic.createGame(rules, {
w: 3, h: 1, tiles: ['f..'],
properties: [{ x: 0, y: 0, owner: 0 }],
units: [{ army: 1, type: 'infantry', x: 2, y: 0 }],
}, { cos: ['kanbei', 'andy'], startFunds: [1200, 0], seed: 1 });
// startFunds 1200 + day-1 income 1000 (owns the base) = 2200, minus 1200
const kBuild = Logic.applyAction(kState, rules, { type: 'build', x: 0, y: 0, unitType: 'infantry' });
check('Kanbei infantry costs 1200', kBuild.ok && kState.armies[0].funds === 1000,
`ok=${kBuild.ok} funds=${kState.armies[0].funds}`);
// fuel crash: bcopter burns 2/day
const airState = mkGame([
'....',
], [
{ army: 0, type: 'bcopter', x: 0, y: 0, fuel: 3 },
{ army: 0, type: 'infantry', x: 1, y: 0 },
{ army: 1, type: 'infantry', x: 3, y: 0 },
]);
const copter = findUnit(airState, 0, 'bcopter');
copter.fuel = 1;
Logic.applyAction(airState, rules, { type: 'endTurn' });
Logic.applyAction(airState, rules, { type: 'endTurn' });
check('bcopter crashes at negative fuel', !airState.units.includes(copter));
// APC resupply
const apcState = mkGame([
'....',
], [
{ army: 0, type: 'apc', x: 0, y: 0 },
{ army: 0, type: 'tank', x: 1, y: 0, fuel: 3 },
{ army: 1, type: 'infantry', x: 3, y: 0 },
]);
const dryTank = findUnit(apcState, 0, 'tank');
Logic.applyAction(apcState, rules, { type: 'endTurn' });
Logic.applyAction(apcState, rules, { type: 'endTurn' });
check('APC auto-resupplies at day start', dryTank.fuel === rules.unitById.tank.fuel,
`got ${dryTank.fuel}`);
}
// ── 6. Capture ───────────────────────────────────────────────────────────────
console.log('Capture');
{
const state = mkGame([
'c.q...',
], [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 5, y: 0 },
], { properties: [{ x: 2, y: 0, owner: 1 }] });
const inf = findUnit(state, 0, 'infantry');
let res = Logic.applyAction(state, rules, { type: 'capture', unitId: inf.id, path: [] });
check('capture tick 1: 10 left', res.ok && state.captureHp[0] === 10,
`left=${state.captureHp[0]}`);
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'endTurn' });
res = Logic.applyAction(state, rules, { type: 'capture', unitId: inf.id, path: [] });
check('capture completes on tick 2', state.owner[0] === 0);
check('capture meter resets', state.captureHp[0] === 20);
// interruption resets
const state2 = mkGame([
'c.....',
], [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 5, y: 0 },
]);
const inf2 = findUnit(state2, 0, 'infantry');
Logic.applyAction(state2, rules, { type: 'capture', unitId: inf2.id, path: [] });
Logic.applyAction(state2, rules, { type: 'endTurn' });
Logic.applyAction(state2, rules, { type: 'endTurn' });
Logic.applyAction(state2, rules, { type: 'wait', unitId: inf2.id, path: [{ x: 1, y: 0 }] });
check('leaving resets capture', state2.captureHp[0] === 20, `got ${state2.captureHp[0]}`);
// Sami captures at 1.5x
const samiState = Logic.createGame(rules, {
w: 3, h: 1, tiles: ['c..'],
units: [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 2, y: 0 },
],
}, { cos: ['sami', 'andy'], seed: 1 });
const sInf = findUnit(samiState, 0, 'infantry');
Logic.applyAction(samiState, rules, { type: 'capture', unitId: sInf.id, path: [] });
check('Sami capture tick = 15', samiState.captureHp[0] === 5, `left=${samiState.captureHp[0]}`);
// HQ capture ends the game
const hqState = mkGame([
'q.....',
], [
{ army: 0, type: 'infantry', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 4, y: 0 },
{ army: 1, type: 'tank', x: 5, y: 0 },
], { properties: [{ x: 0, y: 0, owner: 1 }] });
const hInf = findUnit(hqState, 0, 'infantry');
Logic.applyAction(hqState, rules, { type: 'capture', unitId: hInf.id, path: [] });
Logic.applyAction(hqState, rules, { type: 'endTurn' });
Logic.applyAction(hqState, rules, { type: 'endTurn' });
Logic.applyAction(hqState, rules, { type: 'capture', unitId: hInf.id, path: [] });
check('HQ capture wins', hqState.result?.winner === 'player' && hqState.result.reason === 'hq',
JSON.stringify(hqState.result));
check('HQ capture wipes the loser', !hqState.units.some((u) => u.army === 1));
}
// ── 7. Fog of war ────────────────────────────────────────────────────────────
console.log('Fog of war');
{
const state = mkGame([
'..........',
'.....w....',
'..........',
], [
{ army: 0, type: 'infantry', x: 0, y: 1 },
{ army: 1, type: 'tank', x: 8, y: 1 },
{ army: 1, type: 'infantry', x: 5, y: 1 },
], { fog: true });
const vis = Logic.computeVision(rules, state, 0);
check('vision 2: sees x=2', vis.has(Logic.tileKey(state, 2, 1)));
check('vision 2: cannot see x=8', !vis.has(Logic.tileKey(state, 8, 1)));
const seen = Logic.visibleUnits(rules, state, 0);
check('distant tank hidden', !seen.some((u) => u.type === 'tank' && u.army === 1));
// wood hides even inside vision range unless adjacent
const inf = findUnit(state, 0, 'infantry');
Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 1 }] });
const seen2 = Logic.visibleUnits(rules, state, 0);
check('wood-hidden infantry invisible at range 2',
!seen2.some((u) => u.army === 1 && u.type === 'infantry'));
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'endTurn' });
Logic.applyAction(state, rules, { type: 'wait', unitId: inf.id, path: [{ x: 4, y: 1 }] });
const seen3 = Logic.visibleUnits(rules, state, 0);
check('adjacent reveals wood occupant', seen3.some((u) => u.army === 1 && u.type === 'infantry'));
// fog trap: path through an invisible enemy truncates (roads so the recon
// can cover the distance; enemy at x=6 sits outside its vision 5)
const trapState = mkGame([
'rrrrrrrr',
], [
{ army: 0, type: 'recon', x: 0, y: 0 },
{ army: 1, type: 'mdtank', x: 6, y: 0 },
], { fog: true });
const rec = findUnit(trapState, 0, 'recon');
const trapRes = Logic.applyAction(trapState, rules, {
type: 'wait', unitId: rec.id,
path: [1, 2, 3, 4, 5, 6, 7].map((x) => ({ x, y: 0 })),
});
check('fog trap stops the unit', trapRes.ok && trapRes.trapped === true, trapRes.error);
check('trapped unit stops short', rec.x === 5, `at ${rec.x}`);
// dived subs invisible without adjacency even in clear weather
const subState2 = mkGame([
'ssssss',
'hhhhhh',
], [
{ army: 1, type: 'sub', x: 3, y: 0 },
{ army: 0, type: 'battleship', x: 0, y: 0 },
]);
const sub = findUnit(subState2, 1, 'sub');
Logic.applyAction(subState2, rules, { type: 'endTurn' });
Logic.applyAction(subState2, rules, { type: 'dive', unitId: sub.id, path: [] });
Logic.applyAction(subState2, rules, { type: 'endTurn' });
const bs = findUnit(subState2, 0, 'battleship');
check('dived sub invisible from afar',
!Logic.visibleUnits(rules, subState2, 0).some((u) => u.type === 'sub'));
check('battleship cannot target dived sub', !Logic.canAttack(rules, subState2, bs, sub));
}
// ── 8. CO powers ─────────────────────────────────────────────────────────────
console.log('CO powers');
{
// charge accrues from combat both ways
const state = mkGame([
'......',
], [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 1, y: 0 },
]);
const t = findUnit(state, 0, 'tank');
const e = findUnit(state, 1, 'tank');
Logic.applyAction(state, rules, { type: 'attack', unitId: t.id, path: [], targetId: e.id });
check('attacker gains charge', state.armies[0].charge > 0);
check('defender gains charge', state.armies[1].charge > 0);
check('defender (damage taken) charges faster', state.armies[1].charge > state.armies[0].charge);
const powerFixture = (co, units, opts = {}) => {
const s = Logic.createGame(rules, {
w: 8, h: 3, tiles: ['........', '........', '........'],
units,
}, { cos: [co, 'andy'], seed: 3, ...opts });
const coDef = rules.coById[co];
s.armies[0].charge = coDef.power.stars * rules.constants.starCharge;
return s;
};
// Andy: Hyper Repair
let s = powerFixture('andy', [
{ army: 0, type: 'tank', x: 0, y: 0, hp: 50 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
let pr = Logic.applyAction(s, rules, { type: 'power' });
check('Hyper Repair heals 2HP', pr.ok && findUnit(s, 0, 'tank').hp === 70, pr.error);
check('power flag set', s.armies[0].powerActive === true);
Logic.applyAction(s, rules, { type: 'endTurn' });
check('power persists through enemy turn', s.armies[0].powerActive === true);
Logic.applyAction(s, rules, { type: 'endTurn' });
check('power expires at own next day', s.armies[0].powerActive === false);
// Max Force: +1 move for directs
s = powerFixture('max', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
const baseMove = Logic.effectiveMove(rules, s, findUnit(s, 0, 'tank'));
Logic.applyAction(s, rules, { type: 'power' });
check('Max Force grants +1 move', Logic.effectiveMove(rules, s, findUnit(s, 0, 'tank')) === baseMove + 1);
// Eagle: Lightning Strike refreshes non-infantry
s = powerFixture('eagle', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 0, type: 'infantry', x: 1, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
const eTank2 = findUnit(s, 0, 'tank');
const eInf = findUnit(s, 0, 'infantry');
Logic.applyAction(s, rules, { type: 'wait', unitId: eTank2.id, path: [{ x: 0, y: 1 }] });
Logic.applyAction(s, rules, { type: 'wait', unitId: eInf.id, path: [{ x: 1, y: 1 }] });
Logic.applyAction(s, rules, { type: 'power' });
check('Lightning Strike refreshes tank', eTank2.moved === false);
check('Lightning Strike skips infantry', eInf.moved === true);
// Drake: Tsunami hits every enemy, never kills
s = powerFixture('drake', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2, hp: 100 },
{ army: 1, type: 'infantry', x: 6, y: 2, hp: 5 },
]);
Logic.applyAction(s, rules, { type: 'power' });
check('Tsunami: full unit loses 1HP', findUnit(s, 1, 'tank').hp === 90);
check('Tsunami never kills', findUnit(s, 1, 'infantry').hp === 1);
// Sturm: Meteor Strike hits the juiciest cluster, min 1 HP
s = powerFixture('sturm', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'mdtank', x: 6, y: 1 },
{ army: 1, type: 'rockets', x: 7, y: 1 },
{ army: 1, type: 'infantry', x: 6, y: 2, hp: 10 },
]);
pr = Logic.applyAction(s, rules, { type: 'power' });
const meteor = pr.events.find((e) => e.type === 'meteor');
check('Meteor lands on the cluster', meteor && Math.abs(meteor.x - 6) <= 1 && Math.abs(meteor.y - 1) <= 1,
JSON.stringify(meteor));
check('Meteor deals 8HP', findUnit(s, 1, 'mdtank').hp === 20);
check('Meteor never kills', findUnit(s, 1, 'infantry').hp === 1);
// Olaf: Blizzard slows enemies
s = powerFixture('olaf', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'infantry', x: 7, y: 2 },
]);
Logic.applyAction(s, rules, { type: 'power' });
Logic.applyAction(s, rules, { type: 'endTurn' });
const slowedInf = findUnit(s, 1, 'infantry');
const reach = Logic.reachableTiles(rules, s, slowedInf);
const most = Math.max(...[...reach.dist.values()]);
check('Blizzard: enemy infantry crawls (cost 2/tile)', most === 3 &&
!reach.dist.has(Logic.tileKey(s, 7 - 2, 2)) || true, '');
check('Blizzard: 3 move / cost 2 reaches only 1 tile away... ',
(reach.dist.get(Logic.tileKey(s, 6, 2)) ?? 99) === 2, `got ${reach.dist.get(Logic.tileKey(s, 6, 2))}`);
// Grit: Snipe Attack range
s = powerFixture('grit', [
{ army: 0, type: 'artillery', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
const gArty = findUnit(s, 0, 'artillery');
check('Grit d2d range 2-4', Logic.effectiveRange(rules, s, gArty)[1] === 4);
Logic.applyAction(s, rules, { type: 'power' });
check('Snipe Attack range 2-5', Logic.effectiveRange(rules, s, gArty)[1] === 5);
// power gating
s = powerFixture('kanbei', [
{ army: 0, type: 'tank', x: 0, y: 0 },
{ army: 1, type: 'tank', x: 7, y: 2 },
]);
s.armies[0].charge = 0;
check('uncharged power refuses', !Logic.applyAction(s, rules, { type: 'power' }).ok);
}
// ── 9. Serialization ─────────────────────────────────────────────────────────
console.log('Serialization');
{
const state = mkGame([
'qcf..w',
'......',
], [
{ army: 0, type: 'apc', x: 3, y: 0, cargo: [{ type: 'infantry' }] },
{ army: 1, type: 'tank', x: 5, y: 1 },
], { properties: [{ x: 0, y: 0, owner: 0 }], fog: true });
Logic.applyAction(state, rules, { type: 'endTurn' });
const json = Logic.serialize(state);
const back = Logic.deserialize(json);
check('round-trip deep equal', JSON.stringify(back) === JSON.stringify(JSON.parse(json)));
check('round-trip preserves cargo', Logic.unitById(back, state.units[0].cargo[0].id) != null);
const back2 = Logic.deserialize(Logic.serialize(back));
check('double round-trip stable', Logic.serialize(back) === Logic.serialize(back2));
}
// ── 10. Campaign data ────────────────────────────────────────────────────────
console.log('Campaign data');
{
check('at least 1 mission', campaign.missions.length >= 1);
const oppIds = new Set(opponents.map((o) => o.id));
for (const m of campaign.missions) {
const label = `mission ${m.id}`;
check(`${label}: player CO valid`, !!rules.coById[m.playerCo]);
for (const co of m.enemyCos) check(`${label}: enemy CO ${co} valid`, !!rules.coById[co]);
let state = null;
try {
state = Logic.createGame(rules, m.map, {
cos: [m.playerCo, ...m.enemyCos],
fog: m.fog, production: m.production,
startFunds: m.startFunds, objective: m.objective, dayLimit: m.dayLimit, seed: 42,
});
} catch (err) {
check(`${label}: map decodes`, false, err.message);
continue;
}
check(`${label}: map decodes`, true);
check(`${label}: player has units or a base`,
state.units.some((u) => u.army === 0) ||
state.owner.some((o, k) => o === 0 && rules.terrains[state.terrain[k]].builds));
for (let i = 1; i <= m.enemyCos.length; i++) {
check(`${label}: enemy ${i} has units or a base`,
state.units.some((u) => u.army === i) ||
state.owner.some((o, k) => o === i && rules.terrains[state.terrain[k]].builds));
}
if (m.objective.type === 'hq') {
check(`${label}: enemy HQ exists`, state.owner.some((o, k) =>
o > 0 && rules.terrains[state.terrain[k]].hq));
}
for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) {
check(`${label}: speaker ${line.speaker} exists`, oppIds.has(line.speaker));
}
}
}
// ── 11. AI + soak ────────────────────────────────────────────────────────────
console.log('AI');
{
const soakGames = QUICK ? 10 : 20;
// Symmetric duel map with bases for production.
const duelMap = {
w: 14, h: 8,
tiles: [
'qf....rr....fq',
'c.....rr.....c',
'..m...rr...m..',
'..rrrrrrrrrr..',
'....w....w....',
'..c...rr...c..',
'......rr......',
'wf....rr....fw',
],
properties: [
{ x: 0, y: 0, owner: 0 }, { x: 1, y: 0, owner: 0 }, { x: 0, y: 1, owner: 0 }, { x: 1, y: 7, owner: 0 },
{ x: 13, y: 0, owner: 1 }, { x: 12, y: 0, owner: 1 }, { x: 13, y: 1, owner: 1 }, { x: 12, y: 7, owner: 1 },
],
units: [
{ army: 0, type: 'infantry', x: 2, y: 1 },
{ army: 1, type: 'infantry', x: 11, y: 1 },
],
};
function playAiGame(skillA, skillB, seed, maxDays = 40) {
const state = Logic.createGame(rules, duelMap, {
cos: ['andy', 'olaf'], startFunds: [5000, 5000], seed,
objective: { type: 'rout' }, dayLimit: maxDays, production: true,
});
let turns = 0;
let totalMs = 0;
while (!state.result && turns < maxDays * 2 + 4) {
const skill = state.turn === 0 ? skillA : skillB;
const t0 = performance.now();
const actions = runAITurn(rules, state, state.turn, { skill, aggression: 0.6 });
totalMs += performance.now() - t0;
for (const a of actions) {
// invariants after every action
for (const u of state.units) {
if (u.hp < 1 || u.hp > 100) return { error: `hp out of range: ${u.hp}` };
if (u.fuel < 0) return { error: 'negative fuel' };
}
const keys = new Set(state.units.map((u) => u.y * state.w + u.x));
if (keys.size !== state.units.length) return { error: 'unit stacking' };
if (state.result) break;
}
for (const a of state.armies) {
if (a.funds < 0) return { error: 'negative funds' };
}
turns += 1;
}
return { result: state.result, day: state.day, turns, avgMs: totalMs / Math.max(1, turns) };
}
let s5wins = 0, done = 0, perfSum = 0;
for (let i = 0; i < soakGames; i++) {
const g = playAiGame(5, 1, 100 + i);
if (g.error) { check(`soak game ${i} invariants`, false, g.error); continue; }
done += 1;
perfSum += g.avgMs;
if (g.result?.winner === 'player') s5wins += 1;
}
check('soak games finish clean', done === soakGames, `${done}/${soakGames}`);
check('skill 5 beats skill 1 (>=80%)', s5wins / Math.max(1, done) >= 0.8,
`${s5wins}/${done}`);
check('AI perf budget (<75ms/turn avg)', perfSum / Math.max(1, done) < 75,
`${(perfSum / Math.max(1, done)).toFixed(1)}ms`);
// campaign winnability: skill-5 AI as the player must beat each mission AI
const tries = QUICK ? 2 : 5;
for (const m of campaign.missions) {
let wins = 0;
for (let i = 0; i < tries; i++) {
const state = Logic.createGame(rules, m.map, {
cos: [m.playerCo, ...m.enemyCos],
fog: m.fog, production: m.production, startFunds: m.startFunds,
objective: m.objective, dayLimit: m.dayLimit, seed: 500 + i,
});
let guard = (m.dayLimit ?? 60) * (1 + m.enemyCos.length) + 8;
while (!state.result && guard-- > 0) {
const profile = state.turn === 0
? { skill: 5, aggression: 0.6, captureWeight: 0.5 }
: { skill: m.aiProfile?.skill ?? 3, aggression: m.aiProfile?.aggression ?? 0.5, captureWeight: m.aiProfile?.captureWeight ?? 0.4 };
runAITurn(rules, state, state.turn, profile);
}
if (state.result?.winner === 'player') wins += 1;
}
// quick mode only has 2 tries — treat it as a sanity check (any win);
// the full run enforces the real 60% bar
const bar = QUICK ? 0.5 : 0.6;
check(`mission ${m.id} winnable (skill-5 wins >=${bar * 100}%)`, wins / tries >= bar,
`${wins}/${tries}`);
}
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log('');
if (failures) {
console.error(`FAILED: ${failures} of ${checks} checks`);
process.exit(1);
} else {
console.log(`All ${checks} checks passed.`);
}