From e7da172896eb6d448981746d6931bd6f68ef3bad Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Fri, 24 Jul 2026 20:57:40 -0600 Subject: [PATCH] feat: add Total Annihilation game with campaign, AI, and full engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete Total Annihilation clone including: - TotalAnnihilationGame.js: Main scene with fixed-step loop, input handling, camera controls, selection, orders, and match lifecycle - TAHud.js: Resource strip with flow rates, selection info, build/production grid, minimap with fog of war, and toast notifications - TAScreens.js: Main menu, skirmish setup, campaign list, pause menu, and victory/defeat results - totalannihilation-campaign.json: 6 hand-authored campaign missions with escalating difficulty (skill 1→5) - tools/genTACampaign.js: Campaign generator that bakes terrain from the seeded map generator - tools/verifyTotalAnnihilation.js: 13-section end-to-end test suite covering rules integrity, artwork, economy, pathfinding, combat, fog of war, map generation, serialization, AI skill ladder, and campaign winnability - tools/lib/taStubScene.js: Minimal Phaser stub for headless testing Engine improvements: - TALogic.js: Swept projectile collision (no tunneling), quadratic AoE falloff, manual weapon support (D-Gun), unrecoverable elimination, onTick callback for AI stepping - TAAI.js: Skill-based economy ladders, simultaneous factory+economy growth, sticky focus-fire, home defense gating by skill, tune/profile overrides - TANav.js: fitField dilation for unit clearance, stale fit mask invalidation - TAWorldView.js: _addWorld for layer ordering, frame-accurate zoom anchor math, container depth sorting every frame - TARules.js: autoRangeOf excludes manual weapons, manual flag compilation Art & data: - Mayan civilization artwork added to civilization-artwork.json - Several opponents switched to mayan citySheet - totalannihilation-rules.json: balanced unit stats, new commanderlaser weapon, restructured AI skill tuning, formatted JSON - sprites.md: complete art spec for drop-in PNG sheets - Registered in gamesRegistry, assetManifest, PreloadScene, GameRoomScene, and soundtrack service --- .../civilization-cities-mayan.png | Bin 0 -> 128832 bytes data/civilization-artwork.json | 3 +- data/opponents.json | 12 +- data/totalannihilation-campaign.json | 988 ++++++++++++++++++ data/totalannihilation-rules.json | 980 +++++++++++++---- src/data/assetManifest.js | 17 + src/data/gamesRegistry.js | 1 + src/games/totalannihilation/TAAI.js | 91 +- src/games/totalannihilation/TAArt.js | 19 +- src/games/totalannihilation/TAFx.js | 2 + src/games/totalannihilation/TAHud.js | 398 +++++++ src/games/totalannihilation/TALogic.js | 71 +- src/games/totalannihilation/TANav.js | 47 +- src/games/totalannihilation/TARules.js | 18 +- src/games/totalannihilation/TAScreens.js | 295 ++++++ src/games/totalannihilation/TAWorldView.js | 77 +- .../TotalAnnihilationGame.js | 665 ++++++++++++ src/games/totalannihilation/sprites.md | 173 +++ src/main.js | 2 + src/scenes/GameRoomScene.js | 2 +- src/scenes/PreloadScene.js | 1 + src/services/soundtrack.js | 1 + tools/genTACampaign.js | 141 +++ tools/lib/taStubScene.js | 134 +++ tools/verifyTotalAnnihilation.js | 856 +++++++++++++++ 25 files changed, 4703 insertions(+), 291 deletions(-) create mode 100644 assets/images/civilization/civilization-cities-mayan.png create mode 100644 data/totalannihilation-campaign.json create mode 100644 src/games/totalannihilation/TAHud.js create mode 100644 src/games/totalannihilation/TAScreens.js create mode 100644 src/games/totalannihilation/TotalAnnihilationGame.js create mode 100644 src/games/totalannihilation/sprites.md create mode 100644 tools/genTACampaign.js create mode 100644 tools/lib/taStubScene.js create mode 100644 tools/verifyTotalAnnihilation.js diff --git a/assets/images/civilization/civilization-cities-mayan.png b/assets/images/civilization/civilization-cities-mayan.png new file mode 100644 index 0000000000000000000000000000000000000000..a5a40fd34b0a6b1b7e7c43e98c8e08614a1e81c0 GIT binary patch literal 128832 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

Yn)jq*Z@pF5%Su2-0MNv6 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 O0000 musicFrom(scene, 'nintendo-music'), ], + // Rules always load; every unit/structure/terrain sheet is an optional drop-in + // declared in data/totalannihilation-artwork.json (spec: src/games/ + // totalannihilation/sprites.md) — each is path:null today and paints + // procedurally, so the game is fully playable with zero PNGs. `sheets` is a MAP + // of sheets rather than a list of field names, which is why one resolver line + // covers every sheet ever added: sheetsFrom() Object.values() anything without a + // `key`. Uses the hacker soundtrack (see services/soundtrack.js). + totalannihilation: [ + { type: 'json', key: 'totalannihilation-rules', path: 'data/totalannihilation-rules.json' }, + { type: 'json', key: 'totalannihilation-campaign', path: 'data/totalannihilation-campaign.json' }, + (scene) => sheetsFrom(scene, 'totalannihilation-artwork', ['sheets']), + // Weapon sounds keyed by each weapon's `sound` in totalannihilation-rules.json. + ...['gunfire-modern', 'tank', 'missle'].map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })), + { type: 'audio', key: 'sfx-laser-zap', path: 'assets/fx/laser-zap.mp3' }, + { type: 'audio', key: 'sfx-scifi-launch', path: 'assets/fx/scifi-launch.mp3' }, + (scene) => musicFrom(scene, 'hacker-music'), + ], coloradodefense: [ // arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here // so its audio only downloads once Colorado Defense is actually entered. diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 37d0723..f20d74c 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -115,3 +115,4 @@ registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc', 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 }); registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 }); +registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 }); diff --git a/src/games/totalannihilation/TAAI.js b/src/games/totalannihilation/TAAI.js index 31d8287..6598599 100644 --- a/src/games/totalannihilation/TAAI.js +++ b/src/games/totalannihilation/TAAI.js @@ -25,7 +25,7 @@ function memFor(state, armyIdx) { m = state.aiMem[armyIdx] = { nextThinkTick: 0, ordersThisSecond: 0, secondMark: 0, - squad: [], squadPeak: 0, phase: 'build', + squad: [], squadPeak: 0, phase: 'build', focusId: 0, scoutId: 0, knownEnemies: [], // [{x,y,defId,isBuilding,tick}] lastAttackTick: -99999, @@ -46,7 +46,10 @@ export function runAI(rules, state, armyIdx, profile = {}) { if (!army || !army.alive) return; const skill = Math.max(1, Math.min(5, profile.skill ?? army.aiSkill ?? 3)); - const tuning = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1]; + const base = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1]; + // `tune` lets a mission (or a tuning experiment) override individual dials without + // inventing a whole new skill row. + const tuning = profile.tune ? { ...base, ...profile.tune } : base; const aggression = profile.aggression ?? army.aiProfile?.aggression ?? 0.5; // Economic ambition is primarily a SKILL trait — tuning.expansion runs 0.6..3.0 across // skills 1-5, normalised here to 0.2..1.0. A commander's own aiProfile only nudges it, @@ -68,7 +71,7 @@ export function runAI(rules, state, armyIdx, profile = {}) { mem.ordersThisSecond = 0; } const ctx = { - rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, + rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, profile, budget: Math.max(1, tuning.apmCap), }; @@ -165,7 +168,7 @@ function manageEconomyAndBase(ctx, mine) { } function chooseBuilding(ctx, mine) { - const { rules, army, mem, expansion } = ctx; + const { rules, army, mem, expansion, skill } = ctx; const n = (id) => (mine.byDef[id] ?? 0); const eGen = rules.buildingById.energygen; const mGen = rules.buildingById.massgen; @@ -180,6 +183,19 @@ function chooseBuilding(ctx, mine) { if (n('massgen') < 2) return mGen; if (n('vehicleplant') < 1) return plant; + // Skills 1-2 stop here: a short fixed ladder and no governor at all. Without this the + // bottom of the ladder still runs the same ratio-aware economy as the top and plays a + // perfectly respectable game, which is why difficulty barely registered. + if (skill <= 2) { + const lim = skill === 1 ? 1 : 2; + if (n('energygen') < 3 + lim) return eGen; + if (n('massgen') < 2 + lim) return mGen; + if (n('barracks') + n('vehicleplant') < 2 + lim) { + return n('vehicleplant') <= n('barracks') ? plant : barracks; + } + return null; + } + // Steady state: chase the energy:mass income RATIO that the unit roster actually costs. // A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources // symmetrically ends up capping out on energy while its factories starve for mass. @@ -196,19 +212,33 @@ function chooseBuilding(ctx, mine) { if (army.stallM < 0.98 && roomM) return mGen; if (army.stallE < 0.98 && roomE) return eGen; + + // Production capacity is grown ALONGSIDE the economy, sized to what the income can + // actually feed. Saturating generators first (the obvious ordering) is why a big economy + // used to win nothing: with only two factories the surplus just floated in storage, and an + // army that out-earned its opponent two to one still fielded the same number of tanks. + const factories = n('barracks') + n('vehicleplant'); + const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks); + const factoryCap = Math.round(2 + expansion * 4); + const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant))); + if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory(); + if (ratio > TARGET_RATIO && roomM) return mGen; if (ratio < TARGET_RATIO && roomE) return eGen; if (roomM) return mGen; if (roomE) return eGen; - - // Economy is built out — convert the surplus into production capacity. - const factories = n('barracks') + n('vehicleplant'); - if (factories < Math.round(2 + expansion * 3)) { - return n('vehicleplant') <= n('barracks') ? plant : barracks; - } + if (factories < factoryCap) return nextFactory(); return null; } +/** Mass per second a factory of this type burns when kept busy. */ +function factoryMassDrain(rules, def) { + const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean); + if (!units.length) return 1; + const avg = units.reduce((s, u) => s + u.cost.mass / u.buildTime, 0) / units.length; + return avg * (def.buildPower / rules.constants.buildPowerNominal); +} + /** * Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for * a metal patch, which is what makes map control matter without a reclaim economy. @@ -301,8 +331,8 @@ function randomPick(ctx, builds) { /** Pick whichever buildable unit is furthest below its share of the desired mix. */ function templatePick(ctx, mine, builds) { - const { rules, skill } = ctx; - const weights = { ...COMPOSITION }; + const { rules, skill, profile } = ctx; + const weights = { ...COMPOSITION, ...(profile.composition ?? {}) }; if (skill >= 4) applyCounters(ctx, weights); const total = builds.reduce((s, id) => s + (mine.byDef[id] ?? 0), 0) + 1; @@ -424,8 +454,8 @@ function manageMilitary(ctx, mine) { mem.squad = mem.squad.filter((id) => state.entities.some((e) => e.id === id && !e.dead)); for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id); - // Home defence always wins over pushing out. - const threat = nearestThreatToBase(ctx); + // Home defence always wins over pushing out — skill 1 never thinks to come home. + const threat = skill >= 2 ? nearestThreatToBase(ctx) : null; if (threat) { const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7))); if (defenders.length) { @@ -507,27 +537,40 @@ function focusFire(ctx, mine) { .filter(Boolean); if (members.length < 3) return; - const cx = members.reduce((s, u) => s + u.x, 0) / members.length; - const cy = members.reduce((s, u) => s + u.y, 0) / members.length; + // An `attack` order REPLACES the unit's queue and pins its target, so re-picking one every + // think interrupts the push and leaves the squad ignoring whatever is actually shooting it. + // Focus fire is therefore sticky: hold the current victim until it dies or gets out of + // reach, and only ever commit to something the squad can already shoot without moving. + const focus = state.entities.find((e) => e.id === mem.focusId && !e.dead); + if (focus && isVisibleTo(state, armyIdx, focus)) { + const stillOn = members.filter((u) => inReach(rules, u, focus)).length; + if (stillOn >= 2) return; // current focus is fine; don't churn orders + } + mem.focusId = 0; - let best = null, bestScore = -Infinity; + let best = null, bestScore = -Infinity, bestShooters = null; for (const e of state.entities) { if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue; if (!isVisibleTo(state, armyIdx, e)) continue; - const d = Math.hypot(e.x - cx, e.y - cy); - if (d > state.tileSize * 9) continue; + const shooters = members.filter((u) => inReach(rules, u, e)); + if (shooters.length < Math.max(2, Math.ceil(members.length * 0.4))) continue; const def = rules.defById[e.defId]; // Prefer the closest thing to dying that can still shoot back. - const score = (def.weaponDefs?.length ? 2 : 1) * (1 - e.hp / e.maxHp + 0.3) * (1 - d / (state.tileSize * 9)); - if (score > bestScore) { best = e; bestScore = score; } + const score = (def.weaponDefs?.length ? 2 : 1) * (1.3 - e.hp / e.maxHp) * shooters.length; + if (score > bestScore) { best = e; bestScore = score; bestShooters = shooters; } } if (!best) return; - const shooters = members.filter((u) => Math.hypot(u.x - best.x, u.y - best.y) < rules.defById[u.defId].maxRange * 1.5); - if (shooters.length < 2) return; + mem.focusId = best.id; order(ctx, { - army: armyIdx, unitIds: shooters.map((u) => u.id), + army: armyIdx, unitIds: bestShooters.map((u) => u.id), order: { type: 'attack', targetId: best.id }, }); } +/** True when `u` could fire on `t` from where it already stands. */ +function inReach(rules, u, t) { + const def = rules.defById[u.defId]; + return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange; +} + export { memFor }; diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js index 689af98..3bd09da 100644 --- a/src/games/totalannihilation/TAArt.js +++ b/src/games/totalannihilation/TAArt.js @@ -32,7 +32,18 @@ function frameRng(seed) { * Create a canvas texture laid out as a spritesheet and register its frames. * `at(frame, draw)` translates the context to that frame's origin; `finish()` uploads. */ -function mkCanvasSheet(scene, key, frameW, frameH, cols, count) { +function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) { + // Guard every dimension. A NaN anywhere here reaches createCanvas as a zero-height texture + // and surfaces as an opaque WebGL "no canvas" error a long way from the actual mistake. + const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback); + const frameW = size(wantW, 64); + const frameH = size(wantH, 64); + const cols = size(wantCols, 8); + const count = size(wantCount, 1); + if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) { + console.warn(`[TAArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, ` + + `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`); + } const rows = Math.max(1, Math.ceil(count / cols)); const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH); const ctx = tex.getContext(); @@ -361,7 +372,11 @@ const PROC_PAINTERS = { const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName); const p = theme?.palette ?? {}; const F = art.terrainFrames ?? {}; - const count = Math.max(9, ...Object.values(F).map((v) => v + 1)); + // The JSON carries a `_comment` key alongside the frame numbers, so filter to actual + // numbers — one string in this list turns the max into NaN and the sheet into a + // zero-height canvas. + const frameNums = Object.values(F).filter((v) => Number.isFinite(v)); + const count = Math.max(9, ...frameNums.map((v) => v + 1)); const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count); const speckle = (ctx, S, seed, color, n, r0, r1) => { diff --git a/src/games/totalannihilation/TAFx.js b/src/games/totalannihilation/TAFx.js index ea21c2f..91598df 100644 --- a/src/games/totalannihilation/TAFx.js +++ b/src/games/totalannihilation/TAFx.js @@ -14,6 +14,8 @@ export default class TAFx { // Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts. this.gUnder = scene.add.graphics().setDepth(depths.fxUnder); this.gOver = scene.add.graphics().setDepth(depths.fxOver); + // Depth on a Container child does nothing until the container is sorted; TAWorldView + // re-sorts the world container every frame, which is what puts these layers in place. worldRoot.add(this.gUnder); worldRoot.add(this.gOver); this.list = []; diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js new file mode 100644 index 0000000..a3b5726 --- /dev/null +++ b/src/games/totalannihilation/TAHud.js @@ -0,0 +1,398 @@ +// Total Annihilation — screen-space HUD. +// +// Everything here lives on `view.uiRoot` so the UI camera draws it and the world camera +// ignores it (TAWorldView._setupCameras). The HUD owns no game rules: it reads state and +// calls back into the scene, which is the only thing allowed to issue orders. +// +// The resource strip is the important piece. A flow economy is unreadable without showing +// the RATES and the stall factor — "1200 mass" tells a player nothing, "+18 −24, STALL 74%" +// tells them to build another Mass Generator. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { canAfford, buildOptionsFor } from './TALogic.js'; +import { Tooltip } from '../../ui/Tooltip.js'; + +export const FONT = 'm6x11, "Julius Sans One"'; + +const PANEL = 0x141a24; +const EDGE = 0x39465e; +const TEXT = '#e8eef7'; +const DIM = '#8fa0b8'; + +const BAR_H = 62; // top resource strip +const BOT_H = 190; // bottom command bar +const MINI = 176; // minimap edge length +const BTN_W = 88, BTN_H = 74; // build-menu button size + +/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */ +function shortName(name) { + const words = name.split(' '); + if (words.length > 1) return words.map((w) => w[0]).join('').toUpperCase(); + return name.length > 8 ? name.slice(0, 8) : name; +} + +export default class TAHud { + constructor(scene, rules, state, view, playerArmy, handlers) { + this.scene = scene; + this.rules = rules; + this.state = state; + this.view = view; + this.playerArmy = playerArmy; + this.h = handlers; // { onBuildPick, onProduce, onCommand, onMinimapJump } + this.buildButtons = []; + this.tooltip = new Tooltip(scene); + this.root = scene.add.container(0, 0).setDepth(1000); + view.uiRoot.add(this.root); + + this._buildTopStrip(); + this._buildBottomBar(); + this._buildMinimap(); + this._pulse = 0; + } + + /** Screen rects the camera must NOT pan from — clicks here are UI, not world orders. */ + get blockRects() { + return [ + new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, BAR_H), + new Phaser.Geom.Rectangle(0, GAME_HEIGHT - BOT_H, GAME_WIDTH, BOT_H), + ]; + } + + hitsUi(x, y) { + return this.blockRects.some((r) => Phaser.Geom.Rectangle.Contains(r, x, y)); + } + + // ------------------------------------------------------------------------- + // Top strip: MASS and ENERGY as stored/cap plus flow rates + // ------------------------------------------------------------------------- + + _buildTopStrip() { + const s = this.scene; + const bg = s.add.rectangle(0, 0, GAME_WIDTH, BAR_H, PANEL, 0.94).setOrigin(0, 0); + bg.setStrokeStyle(2, EDGE); + this.root.add(bg); + + this.res = {}; + const mk = (key, label, x, color) => { + const g = s.add.graphics(); + const name = s.add.text(x, 10, label, { fontFamily: FONT, fontSize: '20px', color: DIM }); + const amount = s.add.text(x, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT }); + const flow = s.add.text(x + 300, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT }); + this.root.add([g, name, amount, flow]); + this.res[key] = { g, name, amount, flow, x, color }; + }; + mk('mass', 'MASS', 28, 0x9fb4cf); + mk('energy', 'ENERGY', GAME_WIDTH / 2 - 240, 0xf2c744); + + this.stallText = s.add.text(GAME_WIDTH - 28, 20, '', { + fontFamily: FONT, fontSize: '26px', color: '#ff6b5a', + }).setOrigin(1, 0); + this.clock = s.add.text(GAME_WIDTH - 28, 4, '', { + fontFamily: FONT, fontSize: '20px', color: DIM, + }).setOrigin(1, 0); + this.root.add([this.stallText, this.clock]); + } + + _drawResource(key, stored, cap, income, drain, stall) { + const r = this.res[key]; + const w = 260, h = 12, y = 16; + r.g.clear(); + r.g.fillStyle(0x0b0e14, 1); + r.g.fillRect(r.x + 84, y, w, h); + const frac = cap > 0 ? Math.max(0, Math.min(1, stored / cap)) : 0; + r.g.fillStyle(r.color, 1); + r.g.fillRect(r.x + 84, y, w * frac, h); + r.g.lineStyle(1, EDGE, 1); + r.g.strokeRect(r.x + 84, y, w, h); + r.amount.setText(`${Math.round(stored)} / ${Math.round(cap)}`); + const net = income - drain; + r.flow.setText(`+${income.toFixed(1)} -${drain.toFixed(1)}`); + r.flow.setColor(net < -0.05 ? '#ff9a6b' : '#9ce6a0'); + // Empty and still draining is the state the player must notice. + const critical = stall < 0.98 && stored < cap * 0.02; + r.amount.setColor(critical ? '#ff6b5a' : TEXT); + } + + // ------------------------------------------------------------------------- + // Bottom bar: selection roster, build/production grid, command buttons + // ------------------------------------------------------------------------- + + _buildBottomBar() { + const s = this.scene; + const y = GAME_HEIGHT - BOT_H; + const bg = s.add.rectangle(0, y, GAME_WIDTH, BOT_H, PANEL, 0.94).setOrigin(0, 0); + bg.setStrokeStyle(2, EDGE); + this.root.add(bg); + + this.selTitle = s.add.text(MINI + 60, y + 12, '', { + fontFamily: FONT, fontSize: '26px', color: TEXT, + }); + this.selDetail = s.add.text(MINI + 60, y + 44, '', { + fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3, + }); + this.queueText = s.add.text(MINI + 60, y + 120, '', { + fontFamily: FONT, fontSize: '19px', color: '#9ce6a0', + }); + this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', { + fontFamily: FONT, fontSize: '17px', color: DIM, + }).setOrigin(1, 0.5); + this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]); + + this.gridX = GAME_WIDTH - 24 - 6 * 96; + this.gridY = y + 16; + } + + _clearGrid() { + for (const b of this.buildButtons) b.destroy(); + this.buildButtons.length = 0; + } + + /** + * Repaint the build/production grid for the current selection. Buttons carry the def so + * the scene can tell "start placing a building" from "enqueue a unit" without re-deriving. + */ + _drawGrid(options, kind) { + this._clearGrid(); + const s = this.scene; + const army = this.state.armies[this.playerArmy]; + options.forEach((def, i) => { + const col = i % 6, row = Math.floor(i / 6); + // The container is placed at the button's CENTRE, not its top-left — see below. + const x = this.gridX + col * 96 + BTN_W / 2; + const y = this.gridY + row * 82 + BTN_H / 2; + const affordable = canAfford(this.state, this.playerArmy, def); + const c = s.add.container(x, y); + // A Container's origin is hard-coded to 0.5 and cannot be changed, so setInteractive() + // always builds a hit area CENTRED on the container's position. Children drawn from a + // top-left origin therefore sit half a button down-right of their own hitbox. Every + // child here is centred on (0,0) so the visual and the hit area coincide. + const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1) + .setStrokeStyle(2, affordable ? EDGE : 0x2a3244); + const label = s.add.text(0, -BTN_H / 2 + 12, shortName(def.name), { + fontFamily: FONT, fontSize: '20px', color: affordable ? TEXT : '#5d6b80', + }).setOrigin(0.5, 0); + const cost = s.add.text(0, -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, { + fontFamily: FONT, fontSize: '15px', color: affordable ? DIM : '#4e5a6d', + }).setOrigin(0.5, 0); + c.add([box, label, cost]); + c.setSize(BTN_W, BTN_H); + c.setInteractive({ useHandCursor: true }); + c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1)); + c.on('pointerout', () => box.setFillStyle(affordable ? 0x22304a : 0x1a2130, 1)); + this.tooltip.attachTo(c, () => this._defTip(def)); + c.on('pointerdown', (p, lx, ly, ev) => { + ev?.stopPropagation(); + if (kind === 'build') this.h.onBuildPick(def); + else this.h.onProduce(def, p.event?.shiftKey ? 5 : 1); + }); + this.root.add(c); + this.buildButtons.push(c); + }); + } + + _defTip(def) { + const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`]; + if (def.hp) lines.push(`HP ${def.hp}`); + if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`); + for (const w of def.weaponDefs ?? []) { + lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`); + } + if (def.makes) { + const m = []; + if (def.makes.mass) m.push(`+${def.makes.mass} mass/s`); + if (def.makes.energy) m.push(`+${def.makes.energy} energy/s`); + if (m.length) lines.push(m.join(' ')); + } + if (def.upkeep?.energy) lines.push(`Upkeep ${def.upkeep.energy} energy/s`); + if (def.terrainMultiplier) lines.push('Build on a metal patch for double yield'); + if (def.builds?.length) lines.push(`Builds: ${def.builds.join(', ')}`); + return { title: def.name, lines: lines.map((text) => ({ text })) }; + } + + // ------------------------------------------------------------------------- + // Minimap — a canvas texture rebaked a few times a second, dots stroked live + // ------------------------------------------------------------------------- + + _buildMinimap() { + const s = this.scene; + const x = 20, y = GAME_HEIGHT - BOT_H + 8; + this.miniX = x; this.miniY = y; + const key = `ta-minimap-${s.scene.key}`; + if (s.textures.exists(key)) s.textures.remove(key); + this.miniTex = s.textures.createCanvas(key, this.state.w, this.state.h); + this.miniImg = s.add.image(x, y, key).setOrigin(0, 0) + .setDisplaySize(MINI, MINI).setInteractive({ useHandCursor: true }); + this.miniImg.texture.setFilter(Phaser.Textures.FilterMode.NEAREST); + this.miniG = s.add.graphics(); + this.root.add([this.miniImg, this.miniG]); + + const jump = (p) => { + const fx = Phaser.Math.Clamp((p.x - x) / MINI, 0, 1); + const fy = Phaser.Math.Clamp((p.y - y) / MINI, 0, 1); + this.h.onMinimapJump(fx * this.state.worldW, fy * this.state.worldH); + }; + this.miniImg.on('pointerdown', (p) => { this._miniDrag = true; jump(p); }); + s.input.on('pointerup', () => { this._miniDrag = false; }); + s.input.on('pointermove', (p) => { if (this._miniDrag) jump(p); }); + this._miniBakedAt = -1e9; + } + + _bakeMinimap(timeMs) { + if (timeMs - this._miniBakedAt < 250) return; + this._miniBakedAt = timeMs; + const st = this.state, ctx = this.miniTex.context; + const img = ctx.createImageData(st.w, st.h); + const pal = this.rules.terrain.map((t) => { + const hex = this.view.themePalette?.[t.id] ?? '#404040'; + const c = parseInt(String(hex).slice(1), 16); + return [(c >> 16) & 255, (c >> 8) & 255, c & 255]; + }); + const army = st.armies[this.playerArmy]; + for (let ty = 0; ty < st.h; ty++) { + for (let tx = 0; tx < st.w; tx++) { + const i = ty * st.w + tx; + const [r, g, b] = pal[st.terrain[i]] ?? [64, 64, 64]; + // The vision grid is half tile resolution, and unexplored must read as black: + // a minimap that ignores fog is just a wallhack with extra steps. + const v = (ty >> 1) * st.visW + (tx >> 1); + const k = army.explored[v] ? (army.visible[v] ? 1 : 0.45) : 0; + const o = i * 4; + img.data[o] = r * k; img.data[o + 1] = g * k; img.data[o + 2] = b * k; img.data[o + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); + this.miniTex.refresh(); + } + + _drawMinimapOverlay() { + const g = this.miniG, st = this.state; + g.clear(); + const sx = MINI / st.worldW, sy = MINI / st.worldH; + for (const e of st.entities) { + if (e.dead) continue; + if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue; + const a = this.rules.armies[e.army]; + g.fillStyle(a ? a.colorInt : 0x888888, 1); + const r = e.isBuilding ? 2.5 : 1.6; + g.fillRect(this.miniX + e.x * sx - r, this.miniY + e.y * sy - r, r * 2, r * 2); + } + const cam = this.scene.cameras.main; + g.lineStyle(1.5, 0xffffff, 0.8); + g.strokeRect( + this.miniX + cam.worldView.x * sx, this.miniY + cam.worldView.y * sy, + cam.worldView.width * sx, cam.worldView.height * sy, + ); + } + + // ------------------------------------------------------------------------- + // Per-frame update + // ------------------------------------------------------------------------- + + update(timeMs, selection, placementDef) { + const st = this.state, army = st.armies[this.playerArmy]; + if (!army) return; + + this._drawResource('mass', army.mass, army.massCap, army.mIncome, army.mDrain * army.stallM, army.stallM); + this._drawResource('energy', army.energy, army.energyCap, army.eIncome, (army.eDrain * army.stallE) + army.eUpkeep * army.upkeepFactor, army.stallE); + + const eff = army.buildEff ?? 1; + if (eff < 0.98) { + this._pulse = (this._pulse + 0.12) % (Math.PI * 2); + this.stallText.setText(`STALL ${Math.round(eff * 100)}%`); + this.stallText.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this._pulse))); + } else { + this.stallText.setText(''); + } + const t = Math.floor(st.elapsedSec); + this.clock.setText(`${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`); + + this._bakeMinimap(timeMs); + this._drawMinimapOverlay(); + this._refreshSelection(selection, placementDef); + } + + _refreshSelection(selection, placementDef) { + const sel = [...selection]; + const sig = sel.map((e) => e.id).join(',') + '|' + (placementDef?.id ?? '') + + '|' + sel.map((e) => e.queue?.length ?? 0).join(',') + + '|' + Math.floor((this.state.armies[this.playerArmy]?.mass ?? 0) / 25); + if (sig === this._selSig) { this._refreshQueue(sel); return; } + this._selSig = sig; + + if (!sel.length) { + this.selTitle.setText(''); + this.selDetail.setText(''); + this.queueText.setText(''); + this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups'); + this._clearGrid(); + return; + } + + const counts = new Map(); + for (const e of sel) { + const def = this.rules.defById[e.defId]; + counts.set(def.name, (counts.get(def.name) ?? 0) + 1); + } + this.selTitle.setText([...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(' ')); + + const lead = sel[0]; + const def = this.rules.defById[lead.defId]; + const lines = []; + if (sel.length === 1) { + lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`); + if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`); + if (!lead.site && lead.hasRally) lines.push('Rally point set'); + for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`); + } else { + const hp = sel.reduce((s, e) => s + e.hp, 0), max = sel.reduce((s, e) => s + e.maxHp, 0); + lines.push(`${sel.length} units HP ${Math.ceil(hp)} / ${max}`); + } + this.selDetail.setText(lines.join('\n')); + + if (placementDef) { + this.hint.setText(`Placing ${placementDef.name} — LMB to site it, Esc to cancel`); + } else { + this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups'); + } + + // Grid shows the builder's structures, or the factory's units — never both. + const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); + const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); + if (builder) this._drawGrid(buildOptionsFor(this.rules, builder), 'build'); + else if (factory) this._drawGrid(buildOptionsFor(this.rules, factory), 'produce'); + else this._clearGrid(); + } + + _refreshQueue(sel) { + const f = sel.find((e) => e.isBuilding && !e.site && e.queue?.length); + if (!f) { this.queueText.setText(''); return; } + const parts = f.queue.map((q, i) => { + const name = this.rules.defById[q.defId].name; + const pct = i === 0 ? ` ${Math.round((f.jobProgress ?? 0) * 100)}%` : ''; + return `${name}${q.count > 1 ? ` ×${q.count}` : ''}${pct}`; + }); + this.queueText.setText(`QUEUE ${parts.join(' | ')}`); + } + + toast(msg, color = '#ffd27a') { + if (this._toast) this._toast.destroy(); + this._toast = this.scene.add.text(GAME_WIDTH / 2, BAR_H + 40, msg, { + fontFamily: FONT, fontSize: '28px', color, + }).setOrigin(0.5, 0); + this.view.uiRoot.add(this._toast); + this.scene.tweens.add({ + targets: this._toast, alpha: 0, delay: 1400, duration: 600, + onComplete: () => { this._toast?.destroy(); this._toast = null; }, + }); + } + + destroy() { + this._clearGrid(); + this.tooltip?.destroy(); + this._toast?.destroy(); + this.miniG?.destroy(); + this.miniImg?.destroy(); + this.root.destroy(true); + } +} diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index 2d7a102..57f515f 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -931,6 +931,8 @@ function stepCombat(state, rules) { for (let wi = 0; wi < def.weaponDefs.length; wi++) { const w = def.weaponDefs[wi]; if (e.reload[wi] > 0) continue; + // Manual weapons (the D-Gun) only fire when this exact target was ordered attacked. + if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue; const dist = Math.hypot(dx, dy) - target.radius; if (dist > w.range || dist < (w.minRange ?? 0)) continue; if (!aligned) continue; @@ -949,6 +951,7 @@ function stepCombat(state, rules) { // Continue an in-flight burst. for (let wi = 0; wi < def.weaponDefs.length; wi++) { const w = def.weaponDefs[wi]; + if (w.manual) continue; if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) { fireWeapon(state, rules, e, w, target, facing); e.burstLeft[wi]--; @@ -1083,16 +1086,30 @@ function stepProjectiles(state, rules) { if (--p.ttl <= 0) detonate = true; if (p.x < 0 || p.y < 0 || p.x > state.worldW || p.y > state.worldH) detonate = true; - // Proximity to the aim point (ballistic) or the live target (guided). + // Collision is SWEPT along this tick's segment, not sampled at the new position. A shell + // travels 35px in one 20 Hz tick — well past a 12px hit window and past most unit radii — + // so a point test made tank fire pass clean through whatever it was aimed at. if (!detonate) { const hitR = w.kind === 'guided' ? 14 : 12; - if (Math.hypot(p.aimX - p.x, p.aimY - p.y) <= hitR) detonate = true; + if (segDistSq(p.px, p.py, p.x, p.y, p.aimX, p.aimY) <= hitR * hitR) { + detonate = true; + // Detonate where the shell actually met the aim point, not where the step ended. + const s = segClosestT(p.px, p.py, p.x, p.y, p.aimX, p.aimY); + p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s; + } } // Direct contact with any hostile it passes through. if (!detonate) { + let bestT = Infinity, hit = null; for (const e of state.entities) { if (e.dead || e.site || e.army === p.army) continue; - if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { detonate = true; break; } + if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue; + const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y); + if (s < bestT) { bestT = s; hit = e; } + } + if (hit) { + detonate = true; + p.x = p.px + (p.x - p.px) * bestT; p.y = p.py + (p.y - p.py) * bestT; } } @@ -1115,6 +1132,22 @@ function stepProjectiles(state, rules) { state.projectiles = keep; } +/** Parametric position along segment AB of the point on it closest to P, clamped to [0,1]. */ +function segClosestT(ax, ay, bx, by, px, py) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-9) return 0; + const t = ((px - ax) * dx + (py - ay) * dy) / len2; + return t < 0 ? 0 : t > 1 ? 1 : t; +} + +/** Squared distance from P to segment AB. */ +function segDistSq(ax, ay, bx, by, px, py) { + const t = segClosestT(ax, ay, bx, by, px, py); + const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py; + return cx * cx + cy * cy; +} + function applyAoe(state, rules, x, y, w, source) { const r = w.aoe; const falloff = w.aoeFalloff ?? 0.4; @@ -1124,7 +1157,10 @@ function applyAoe(state, rules, x, y, w, source) { const d = Math.hypot(e.x - x, e.y - y) - e.radius; if (d > r) continue; const t = Math.max(0, Math.min(1, d / r)); - const scale = 1 - (1 - falloff) * t; + // Quadratic falloff, not linear. Linear left near-full damage on every neighbour of the + // unit actually hit, so one shell wiped a whole packed formation and splash weapons beat + // everything else on cost regardless of what they were pointed at. + const scale = falloff + (1 - falloff) * (1 - t) * (1 - t); const mul = w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1; applyDamage(state, rules, e, w.damage * mul * scale, source); } @@ -1218,21 +1254,27 @@ export function isVisibleTo(state, armyIdx, e) { function checkResult(state, rules) { if (state.over) return; - const commanderEnds = rules.constants.commanderDeathEnds !== false; + const unrecoverableEnds = rules.constants.eliminateWhenUnrecoverable !== false; for (const a of state.armies) { if (!a.alive) continue; - let hasAny = false, hasBuilder = false; + let hasAny = false, hasBuilder = false, hasFactory = false; for (const e of state.entities) { if (e.dead || e.army !== a.idx || e.site) continue; hasAny = true; const def = rules.defById[e.defId]; - if (!e.isBuilding && (def.builds ?? []).length) hasBuilder = true; + if (!(def.builds ?? []).length) continue; + if (e.isBuilding) hasFactory = true; else hasBuilder = true; } - // TA's "Commander Ends" rule. Without it a match can sit at the time limit while the - // loser hides one Mass Generator in a corner with no way to ever build again. - if (!hasAny || (commanderEnds && !hasBuilder)) { + // Elimination is "can never produce again", not "lost the Commander". Ending on Commander + // death made a single lucky raid decide matches, which flattened the skill ladder — economy + // and army quality never got to matter. An army that still owns a factory can rebuild, so it + // stays in; one with neither a mobile builder nor a factory is only running down the clock. + const unrecoverable = unrecoverableEnds && !hasBuilder && !hasFactory; + if (!hasAny || unrecoverable) { a.alive = false; - state.events.push({ t: 'armyEliminated', army: a.idx, reason: hasAny ? 'commander' : 'wiped' }); + state.events.push({ + t: 'armyEliminated', army: a.idx, reason: hasAny ? 'unrecoverable' : 'wiped', + }); } } const alive = state.armies.filter((a) => a.alive); @@ -1277,12 +1319,17 @@ export function tick(state, rules) { * in state so the verify script runs the identical loop headlessly, and `state.alpha` gives * the view its interpolation factor between the last two ticks. */ -export function step(state, rules, deltaMs, maxSteps = 4) { +/** + * @param {function} [onTick] runs just BEFORE each fixed step — where the AI belongs, so its + * think cadence is measured in sim ticks rather than riding on the render framerate. + */ +export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) { const out = []; state.accumulatorMs += deltaMs; let n = 0; while (state.accumulatorMs >= rules.stepMs && n < maxSteps) { state.accumulatorMs -= rules.stepMs; + if (onTick) onTick(state); const ev = tick(state, rules); for (let i = 0; i < ev.length; i++) out.push(ev[i]); n++; diff --git a/src/games/totalannihilation/TANav.js b/src/games/totalannihilation/TANav.js index bda0056..956eb16 100644 --- a/src/games/totalannihilation/TANav.js +++ b/src/games/totalannihilation/TANav.js @@ -82,6 +82,7 @@ export function refreshRegion(nav, rules, x0, y0, x1, y1) { // affect tiles up-left of it — expand the rect that way and sweep in reverse. const cx0 = Math.max(0, x0 - MAX_CLEARANCE); const cy0 = Math.max(0, y0 - MAX_CLEARANCE); + nav._fit = null; // clearance moved, so every dilated fit mask is stale for (const mc of nav.moveClassIds) { const pass = nav.passable[mc]; const cl = nav.clearance[mc]; @@ -123,10 +124,50 @@ export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav export const tileCenterX = (nav, tx) => tx * nav.tileSize + nav.tileSize / 2; export const tileCenterY = (nav, ty) => ty * nav.tileSize + nav.tileSize / 2; +/** + * Per-tile "a unit this wide can stand here" mask, cached per move class and need. + * + * clearance[i] is the largest all-passable square whose TOP-LEFT corner is tile i, so + * reading it directly asks "does the square start here" — which wrongly rejects every tile + * along the bottom and right edge of an open area, even though a unit centred there fits + * perfectly well. A tile is genuinely usable when ANY anchor covering it qualifies, so this + * dilates the clearance test by the need window (a separable two-pass OR). + */ +export function fitField(nav, mc, need) { + if (need <= 1) return nav.clearance[mc]; + const key = `${mc}:${need}`; + if (!nav._fit) nav._fit = Object.create(null); + const hit = nav._fit[key]; + if (hit) return hit; + + const { w, h } = nav; + const cl = nav.clearance[mc]; + const rowPass = new Uint8Array(w * h); + for (let y = 0; y < h; y++) { + let run = 0; // how many of the last `need` tiles to the left qualify as anchors + for (let x = 0; x < w; x++) { + const i = y * w + x; + run = cl[i] >= need ? need : (run > 0 ? run - 1 : 0); + rowPass[i] = run > 0 ? 1 : 0; + } + } + const out = new Uint8Array(w * h); + for (let x = 0; x < w; x++) { + let run = 0; + for (let y = 0; y < h; y++) { + const i = y * w + x; + run = rowPass[i] ? need : (run > 0 ? run - 1 : 0); + out[i] = run > 0 ? need : 0; + } + } + nav._fit[key] = out; + return out; +} + /** Is this tile usable by a unit with the given move class and clearance need? */ export function tileOk(nav, mc, need, tx, ty) { if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false; - return nav.clearance[mc][ty * nav.w + tx] >= need; + return fitField(nav, mc, need)[ty * nav.w + tx] >= need; } /** @@ -216,7 +257,7 @@ const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1]; */ export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) { if (startIdx === goalIdx) return Int32Array.of(startIdx); - const cl = nav.clearance[mc]; + const cl = fitField(nav, mc, need); const cost = nav.cost[mc]; if (cl[goalIdx] < need || cl[startIdx] < need) return null; @@ -297,7 +338,7 @@ function reconstruct(came, startIdx, goalIdx) { */ export function segmentClear(nav, mc, need, x0, y0, x1, y1) { const ts = nav.tileSize; - const cl = nav.clearance[mc]; + const cl = fitField(nav, mc, need); let tx = Math.floor(x0 / ts), ty = Math.floor(y0 / ts); const tx1 = Math.floor(x1 / ts), ty1 = Math.floor(y1 / ts); const dx = x1 - x0, dy = y1 - y0; diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index 21d0aa6..97d57e6 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -42,6 +42,16 @@ function requirePositive(obj, keys, what) { } } +/** + * Acquisition range. Manual weapons are excluded so a Commander doesn't chase enemies into + * D-Gun range it will never voluntarily use; a manual-only def falls back to its full reach + * so it still knows when something is worth shooting at. + */ +function autoRangeOf(weaponDefs) { + const auto = weaponDefs.filter((w) => !w.manual); + return (auto.length ? auto : weaponDefs).reduce((m, w) => Math.max(m, w.range), 0); +} + function indexById(list) { const m = Object.create(null); for (const e of list) m[e.id] = e; @@ -163,6 +173,10 @@ export function compileRules(json) { w.rangeSq = w.range * w.range; w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0); w.targetsAir = (w.targets ?? ['ground']).includes('air'); + // A manual weapon never auto-fires: it needs an explicit attack order on the victim. + // The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander + // deletes one attacker every reload for free, which makes assaulting any base suicide. + w.manual = w.manual === true; } const weaponById = indexById(weapons); @@ -193,7 +207,7 @@ export function compileRules(json) { u.sightSq = u.sight * u.sight; u.isBuilding = false; u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]); - u.maxRange = u.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0); + u.maxRange = autoRangeOf(u.weaponDefs); u.spritePx = u.spritePx ?? sc.radius * 2; buildable[u.id] = u; } @@ -221,7 +235,7 @@ export function compileRules(json) { b.sight = b.sight ?? 0; b.sightSq = b.sight * b.sight; b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]); - b.maxRange = b.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0); + b.maxRange = autoRangeOf(b.weaponDefs); buildable[b.id] = b; } const buildingById = indexById(buildings); diff --git a/src/games/totalannihilation/TAScreens.js b/src/games/totalannihilation/TAScreens.js new file mode 100644 index 0000000..353143e --- /dev/null +++ b/src/games/totalannihilation/TAScreens.js @@ -0,0 +1,295 @@ +// Total Annihilation — front-end screens. +// +// Each builder returns a plain `{ destroy() }` handle and the scene keeps exactly one alive +// (TotalAnnihilationGame.swapScreen), which is the AdvanceWarsScreens.js router pattern. +// Screens never touch the simulation: they collect a choice and hand it back through a +// callback, so the same match can be started from a menu, a save, or the verify script. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { createOpponentPortrait } from '../../ui/Portrait.js'; +import { FONT } from './TAHud.js'; + +const BG = 0x0b0f16; +const PANEL = 0x161d29; +const EDGE = 0x39465e; +const ACCENT = 0x7dc4ff; +const TEXT = '#e8eef7'; +const DIM = '#93a3ba'; + +function screen(scene, objs) { + return { + objs, + destroy() { for (const o of objs) o.destroy?.(); objs.length = 0; }, + }; +} + +function backdrop(scene, objs, title, subtitle) { + objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0).setDepth(900) + .setInteractive()); // swallow clicks so they never reach the world beneath + const t = scene.add.text(GAME_WIDTH / 2, 92, title, { + fontFamily: FONT, fontSize: '76px', color: TEXT, + }).setOrigin(0.5).setDepth(901); + objs.push(t); + if (subtitle) { + objs.push(scene.add.text(GAME_WIDTH / 2, 158, subtitle, { + fontFamily: FONT, fontSize: '24px', color: DIM, + }).setOrigin(0.5).setDepth(901)); + } + return t; +} + +function button(scene, objs, x, y, w, h, label, onClick, { enabled = true, size = 28 } = {}) { + const c = scene.add.container(x, y).setDepth(902); + const bg = scene.add.rectangle(0, 0, w, h, enabled ? 0x22304a : 0x1a2130, 1) + .setStrokeStyle(2, enabled ? EDGE : 0x2a3244); + const txt = scene.add.text(0, 0, label, { + fontFamily: FONT, fontSize: `${size}px`, color: enabled ? TEXT : '#5d6b80', + }).setOrigin(0.5); + c.add([bg, txt]); + // A Container's origin is fixed at 0.5 (read-only in Phaser), so setInteractive() always + // produces a hit area centred on the container's position. Both children above are drawn + // centred on (0,0) to match; a child with a top-left origin would render half a button + // down-right of the area that actually receives the click. + c.setSize(w, h); + if (enabled) { + c.setInteractive({ useHandCursor: true }); + c.on('pointerover', () => bg.setFillStyle(0x2e3f5f, 1)); + c.on('pointerout', () => bg.setFillStyle(0x22304a, 1)); + c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); }); + } + objs.push(c); + c.label = txt; c.bg = bg; + return c; +} + +/** A labelled left/right cycler over a list of options. */ +function cycler(scene, objs, x, y, label, options, index, onChange) { + objs.push(scene.add.text(x, y - 30, label, { + fontFamily: FONT, fontSize: '20px', color: DIM, + }).setOrigin(0, 0.5).setDepth(902)); + const value = scene.add.text(x + 150, y, String(options[index].label), { + fontFamily: FONT, fontSize: '28px', color: TEXT, + }).setOrigin(0.5).setDepth(902); + objs.push(value); + let i = index; + const step = (d) => { + i = (i + d + options.length) % options.length; + value.setText(String(options[i].label)); + onChange(options[i].value, i); + }; + button(scene, objs, x + 26, y, 40, 40, '<', () => step(-1), { size: 22 }); + button(scene, objs, x + 274, y, 40, 40, '>', () => step(1), { size: 22 }); + return { get value() { return options[i].value; } }; +} + +// --------------------------------------------------------------------------- +// Main menu +// --------------------------------------------------------------------------- + +export function mainMenu(scene, { hasSave, onSkirmish, onCampaign, onContinue, onLeave }) { + const objs = []; + backdrop(scene, objs, 'TOTAL ANNIHILATION', 'Commander — your war machine awaits'); + + const cx = GAME_WIDTH / 2; + let y = 330; + button(scene, objs, cx, y, 460, 76, 'SKIRMISH', onSkirmish); y += 100; + button(scene, objs, cx, y, 460, 76, 'CAMPAIGN', onCampaign); y += 100; + button(scene, objs, cx, y, 460, 76, 'CONTINUE', onContinue, { enabled: hasSave }); y += 100; + button(scene, objs, cx, y, 460, 76, 'BACK TO ARCADE', onLeave); + + objs.push(scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 56, + 'Build power and mass, field an army, and leave nothing standing.', { + fontFamily: FONT, fontSize: '20px', color: DIM, + }).setOrigin(0.5).setDepth(901)); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Skirmish setup +// --------------------------------------------------------------------------- + +export function skirmishSetup(scene, { onBack, onStart }) { + const objs = []; + const rules = scene.rules; + backdrop(scene, objs, 'SKIRMISH', 'Generated map — every seed is a new battlefield'); + + const sk = rules.skirmish ?? {}; + const sizes = Object.keys(sk.sizes ?? { small: 64, medium: 96, large: 128 }) + .map((k) => ({ label: `${k.toUpperCase()} ${sk.sizes?.[k] ?? ''}`.trim(), value: k })); + const themes = Object.entries(scene.art.themes ?? {}).map(([id, t]) => ({ label: (t.name ?? id).toUpperCase(), value: id })); + const syms = (sk.symmetries ?? ['mirror-x', 'mirror-y', 'rotational']) + .map((s) => ({ label: s.replace('-', ' ').toUpperCase(), value: s })); + const skills = [1, 2, 3, 4, 5].map((n) => ({ label: `SKILL ${n}`, value: n })); + + const cfg = { + seed: (Math.random() * 0xffffffff) >>> 0, + size: sizes[0].value, + theme: themes[0]?.value ?? 'grasslands', + symmetry: syms[0].value, + armies: [ + { armyId: rules.armies[0].id, commanderId: rules.commanders[0]?.id, skill: 3 }, + { armyId: rules.armies[1].id, commanderId: rules.commanders[1]?.id, skill: 3 }, + ], + }; + + const left = 300, right = GAME_WIDTH - 700; + cycler(scene, objs, left, 300, 'MAP SIZE', sizes, 0, (v) => { cfg.size = v; }); + cycler(scene, objs, left, 400, 'THEME', themes, 0, (v) => { cfg.theme = v; }); + cycler(scene, objs, left, 500, 'SYMMETRY', syms, 0, (v) => { cfg.symmetry = v; }); + cycler(scene, objs, left, 600, 'OPPONENT SKILL', skills, 2, (v) => { cfg.armies[1].skill = v; }); + + const seedText = scene.add.text(left, 690, `SEED ${cfg.seed}`, { + fontFamily: FONT, fontSize: '22px', color: DIM, + }).setOrigin(0, 0.5).setDepth(902); + objs.push(seedText); + button(scene, objs, left + 420, 690, 150, 44, 'REROLL', () => { + cfg.seed = (Math.random() * 0xffffffff) >>> 0; + seedText.setText(`SEED ${cfg.seed}`); + }, { size: 20 }); + + // Commander picker — the armies double as the shared opponent personas. + const portraits = []; + const drawCommanders = () => { + for (const p of portraits) p.destroy?.(); + portraits.length = 0; + rules.commanders.forEach((c, i) => { + const col = i % 2, row = Math.floor(i / 2); + const x = right + col * 240, y = 320 + row * 210; + const chosen = cfg.armies[1].commanderId === c.id; + const box = scene.add.rectangle(x, y, 210, 190, chosen ? 0x2b3f5e : PANEL, 1) + .setStrokeStyle(2, chosen ? ACCENT : EDGE).setDepth(901) + .setInteractive({ useHandCursor: true }); + box.on('pointerdown', () => { + cfg.armies[1].commanderId = c.id; + cfg.armies[1].armyId = c.armyId; + // The player always takes the other side, so a CORE opponent means an ARM player. + const mine = rules.commanders.find((o) => o.armyId !== c.armyId) ?? rules.commanders[0]; + cfg.armies[0].commanderId = mine.id; + cfg.armies[0].armyId = mine.armyId; + drawCommanders(); + }); + const label = scene.add.text(x, y + 66, c.name, { + fontFamily: FONT, fontSize: '20px', color: chosen ? TEXT : DIM, + }).setOrigin(0.5).setDepth(903); + portraits.push(box, label); + objs.push(box, label); + try { + const opp = scene.oppById?.[c.opponentId]; + if (opp) { + const port = createOpponentPortrait(scene, opp, x, y - 24, 52, 902, { playIntro: false }); + for (const o of [].concat(port ?? [])) if (o) { portraits.push(o); objs.push(o); } + } + } catch (_) { /* portraits are decorative — a missing one must not block play */ } + }); + }; + objs.push(scene.add.text(right + 120, 250, 'ENEMY COMMANDER', { + fontFamily: FONT, fontSize: '22px', color: DIM, + }).setOrigin(0.5).setDepth(902)); + drawCommanders(); + + button(scene, objs, GAME_WIDTH / 2 - 180, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack); + button(scene, objs, GAME_WIDTH / 2 + 180, GAME_HEIGHT - 110, 300, 76, 'LAUNCH', () => onStart(cfg)); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Campaign list +// --------------------------------------------------------------------------- + +export function campaignList(scene, campaign, cleared, { onBack, onPick }) { + const objs = []; + const missions = campaign.missions ?? []; + backdrop(scene, objs, 'CAMPAIGN', + missions.length ? `${cleared} of ${missions.length} missions cleared` : 'No missions installed yet'); + + missions.forEach((m, i) => { + const col = i % 3, row = Math.floor(i / 3); + const x = GAME_WIDTH / 2 + (col - 1) * 500, y = 300 + row * 130; + // Missions unlock in order — the same rule the puzzle_progress endpoint enforces. + const unlocked = i <= cleared; + const b = button(scene, objs, x, y, 460, 104, + `${String(i + 1).padStart(2, '0')} ${unlocked ? m.name : 'LOCKED'}`, + () => onPick(i), { enabled: unlocked, size: 26 }); + if (unlocked && i < cleared) b.label.setColor('#9ce6a0'); + }); + + button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Pause +// --------------------------------------------------------------------------- + +export function pause(scene, { settings, onResume, onSave, onQuit, onToggleFog, onToggleEdge, onSpeed }) { + const objs = []; + objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62) + .setOrigin(0, 0).setDepth(900).setInteractive()); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const panel = scene.add.rectangle(cx, cy, 620, 640, PANEL, 0.98) + .setStrokeStyle(3, EDGE).setDepth(901); + objs.push(panel); + objs.push(scene.add.text(cx, cy - 260, 'PAUSED', { + fontFamily: FONT, fontSize: '54px', color: TEXT, + }).setOrigin(0.5).setDepth(902)); + + const fogBtn = button(scene, objs, cx, cy - 150, 440, 62, + `FOG OF WAR: ${settings.fog !== false ? 'ON' : 'OFF'}`, () => { + const on = !(settings.fog !== false); + onToggleFog(on); + fogBtn.label.setText(`FOG OF WAR: ${on ? 'ON' : 'OFF'}`); + }, { size: 24 }); + + const edgeBtn = button(scene, objs, cx, cy - 76, 440, 62, + `EDGE SCROLL: ${settings.edgeScroll !== false ? 'ON' : 'OFF'}`, () => { + const on = !(settings.edgeScroll !== false); + onToggleEdge(on); + edgeBtn.label.setText(`EDGE SCROLL: ${on ? 'ON' : 'OFF'}`); + }, { size: 24 }); + + const speeds = [0.5, 1, 2]; + let si = 1; + const speedBtn = button(scene, objs, cx, cy - 2, 440, 62, 'SPEED: 1x', () => { + si = (si + 1) % speeds.length; + onSpeed(speeds[si]); + speedBtn.label.setText(`SPEED: ${speeds[si]}x`); + }, { size: 24 }); + + button(scene, objs, cx, cy + 88, 440, 66, 'RESUME', onResume); + button(scene, objs, cx, cy + 166, 440, 66, 'SAVE', onSave); + button(scene, objs, cx, cy + 244, 440, 66, 'QUIT TO MENU', onQuit); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +export function results(scene, { won, stats, elapsed }, { onMenu, onAgain }) { + const objs = []; + objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7) + .setOrigin(0, 0).setDepth(900).setInteractive()); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + objs.push(scene.add.rectangle(cx, cy, 760, 560, PANEL, 0.98).setStrokeStyle(3, EDGE).setDepth(901)); + objs.push(scene.add.text(cx, cy - 210, won ? 'VICTORY' : 'DEFEAT', { + fontFamily: FONT, fontSize: '72px', color: won ? '#9ce6a0' : '#ff8a6b', + }).setOrigin(0.5).setDepth(902)); + + const mins = Math.floor(elapsed / 60), secs = Math.floor(elapsed % 60); + const lines = [ + `Duration ${mins}m ${String(secs).padStart(2, '0')}s`, + `Units built ${stats.built}`, + `Units lost ${stats.lost}`, + `Kills ${stats.kills}`, + `Mass left ${stats.mass}`, + `Energy left ${stats.energy}`, + ]; + objs.push(scene.add.text(cx, cy - 100, lines.join('\n'), { + fontFamily: FONT, fontSize: '26px', color: TEXT, align: 'left', lineSpacing: 10, + }).setOrigin(0.5, 0).setDepth(902)); + + button(scene, objs, cx - 170, cy + 210, 300, 72, 'PLAY AGAIN', onAgain); + button(scene, objs, cx + 170, cy + 210, 300, 72, 'MENU', onMenu); + return screen(scene, objs); +} diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 9200b06..b49b532 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -61,9 +61,9 @@ export default class TAWorldView { this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection); this.gBars = scene.add.graphics().setDepth(DEPTHS.bars); this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost); - this.worldRoot.add(this.gSelection); - this.worldRoot.add(this.gBars); - this.worldRoot.add(this.gGhost); + this._addWorld(this.gSelection); + this._addWorld(this.gBars); + this._addWorld(this.gGhost); this.sprites = new Map(); // entity id -> { img, turret } this.selection = new Set(); @@ -90,6 +90,22 @@ export default class TAWorldView { cam.ignore(this.uiRoot); } + /** + * Add a child to the world container. + * + * Phaser Containers render their children in INSERTION order and never sort by depth on + * their own — `sortChildrenFlag` exists on DisplayList and Layer, not Container. So depth + * here is inert until `sort('depth')` runs, which is why the fog image (built in the + * constructor, long before the terrain chunks it must cover) was drawn underneath the map + * and the whole battlefield was visible. Anything added mid-frame — a lazily painted + * terrain chunk, a new unit sprite — re-dirties the order. + */ + _addWorld(obj) { + this.worldRoot.add(obj); + this._layerDirty = true; + return obj; + } + /** Keep screen-space extras (music controls, fullscreen button) off the world camera. */ ignoreOnWorldCam(objs) { if (!objs?.length) return; @@ -103,22 +119,50 @@ export default class TAWorldView { centerOn(x, y) { this.scene.cameras.main.centerOn(x, y); } + /** + * Zoom one step, keeping whatever the cursor is over pinned under the cursor. + * + * The scroll is solved directly from the camera's screen->world mapping rather than by + * diffing two getWorldPoint() calls around the zoom change. getWorldPoint inverts + * `camera.matrix`, and that matrix is only rebuilt in preRender — so the second call reads + * the OLD zoom while the camera already holds the new one, and the correction it yields is + * wrong. Solving it makes the anchor exact and independent of when in the frame this runs. + * + * At the edges of the map the scroll clamps to the camera bounds, so the anchor gives way + * rather than letting the view slide off the world. + */ zoomBy(dir, focusX, focusY) { const cam = this.scene.cameras.main; const next = Math.max(0, Math.min(ZOOMS.length - 1, this.zoomIdx + dir)); if (next === this.zoomIdx) return; - // Zoom toward the cursor: keep the world point under the pointer pinned. - const before = cam.getWorldPoint(focusX, focusY); + const anchor = this.worldPoint(focusX, focusY); this.zoomIdx = next; - cam.setZoom(ZOOMS[next]); - const after = cam.getWorldPoint(focusX, focusY); - cam.setScroll(cam.scrollX + (before.x - after.x), cam.scrollY + (before.y - after.y)); + const z = ZOOMS[next]; + cam.setZoom(z); + cam.setScroll( + anchor.x - cam.width / 2 + cam.width / (2 * z) - focusX / z, + anchor.y - cam.height / 2 + cam.height / (2 * z) - focusY / z, + ); } get zoom() { return this.scene.cameras.main.zoom; } + /** + * Screen -> world, from the camera's live scroll and zoom. + * + * Deliberately not Camera.getWorldPoint(): that inverts a matrix rebuilt only during + * preRender, so it reports a stale transform for any call made after the camera has moved + * earlier in the same frame — which is every click, since camera panning runs first in + * update(). This mirrors Camera.preRender's own maths: + * worldView.x = scrollX + width/2 - width/(2*zoom), worldX = worldView.x + screenX/zoom + */ worldPoint(screenX, screenY) { - return this.scene.cameras.main.getWorldPoint(screenX, screenY); + const cam = this.scene.cameras.main; + const z = cam.zoom; + return { + x: cam.scrollX + cam.width / 2 - cam.width / (2 * z) + screenX / z, + y: cam.scrollY + cam.height / 2 - cam.height / (2 * z) + screenY / z, + }; } // ------------------------------------------------------------------------- @@ -142,7 +186,7 @@ export default class TAWorldView { const w = Math.min(CHUNK_PX, state.worldW - ox); const h = Math.min(CHUNK_PX, state.worldH - oy); const rt = this.scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0).setDepth(DEPTHS.terrain); - this.worldRoot.add(rt); + this._addWorld(rt); const t0 = Math.floor(ox / ts), t1 = Math.ceil((ox + w) / ts); const r0 = Math.floor(oy / ts), r1 = Math.ceil((oy + h) / ts); @@ -219,7 +263,7 @@ export default class TAWorldView { // Linear filtering upscales the coarse grid into a soft gradient for the cost of a few // hundred pixels of texture — far cheaper than a per-tile fog RenderTexture. this.fogTex.setFilter(1); // Phaser.Textures.FilterMode.LINEAR - this.worldRoot.add(this.fogImg); + this._addWorld(this.fogImg); this.fogDirty = true; } @@ -277,14 +321,14 @@ export default class TAWorldView { } else { img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w); } - this.worldRoot.add(img); + this._addWorld(img); let turret = null; if (!def.isBuilding && def.turretFrame != null) { turret = this.scene.add.image(e.x, e.y, key, def.turretFrame); turret.setTint(sheets.color); turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w); - this.worldRoot.add(turret); + this._addWorld(turret); } s = { img, turret, defId: e.defId, site: e.site }; this.sprites.set(e.id, s); @@ -371,6 +415,11 @@ export default class TAWorldView { this._drawPlacementGhost(); + // Actor depth is recomputed every frame from Y, so the container has to be re-sorted + // every frame too — not just when a child is added. + this.worldRoot.sort('depth'); + this._layerDirty = false; + // Nanolathe links: builder -> whatever it is working on. const nanoLinks = []; for (const e of state.entities) { @@ -432,7 +481,7 @@ export default class TAWorldView { if (!on) return; const { state, rules, ts } = this; const g = this.scene.add.graphics().setDepth(DEPTHS.decal); - this.worldRoot.add(g); + this._addWorld(g); g.lineStyle(2, 0xffd27a, 0.85); for (let ty = 0; ty < state.h; ty++) { for (let tx = 0; tx < state.w; tx++) { diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js new file mode 100644 index 0000000..07cf16a --- /dev/null +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -0,0 +1,665 @@ +// Total Annihilation — the Phaser scene. +// +// A thin coordinator: it owns the fixed-step loop, input bindings and screen routing, and +// holds NO game rules. Every state change goes through TALogic.issueOrder, the same entry +// point the AI uses — which is what lets tools/verifyTotalAnnihilation.js bot the human side +// and assert a campaign mission is actually winnable. +// +// The loop is TALogic.step(state, rules, deltaMs): the accumulator lives inside the headless +// state (the StarControlLogic.stepMatch idiom), so the verify script drives an identical loop +// in Node with a synthetic delta and gets identical results. + +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 { compileRules } from './TARules.js'; +import { generateMap, decodeMap } from './TAMapGen.js'; +import * as Logic from './TALogic.js'; +import { runAI } from './TAAI.js'; +import TAWorldView, { DEPTHS } from './TAWorldView.js'; +import TAFx from './TAFx.js'; +import TAHud from './TAHud.js'; +import * as Screens from './TAScreens.js'; + +const SAVE_KEY = 'totalannihilation-save'; +const SETTINGS_KEY = 'totalannihilation-settings'; +const EDGE_PAN = 26; // screen-edge scroll band, px +const PAN_SPEED = 1100; // px/s at zoom 1 +const DRAG_MIN = 8; // px before a click becomes a drag-box + +export default class TotalAnnihilationGame extends Phaser.Scene { + constructor() { super('TotalAnnihilationGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'totalannihilation', name: 'Total Annihilation' }; + this.phase = 'menu'; // menu | setup | briefing | playing | paused | result + this.match = null; + this.view = null; + this.hud = null; + this.fx = null; + this.screen = null; + this.selection = new Set(); + this.groups = new Map(); + this.placement = null; + this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack' + this.simSpeed = 1; + this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true }); + } + + create() { + try { + const { tracks, volume } = getGameSoundtrack(this); + if (tracks.length) this.music = new MusicPlayer(this, tracks, volume); + } catch (_) { /* soundtrack is optional */ } + + this.input.mouse?.disableContextMenu(); + + this.rules = compileRules(this.cache.json.get('totalannihilation-rules')); + this.art = this.cache.json.get('totalannihilation-artwork'); + this.campaign = this.cache.json.get('totalannihilation-campaign') ?? { missions: [] }; + + this.keys = this.input.keyboard.addKeys( + 'W,A,S,D,X,H,P,G,Q,E,ESC,SPACE,UP,DOWN,LEFT,RIGHT,SHIFT,CTRL', + ); + this._bindInput(); + + // Commander portraits come from the shared opponent roster; a fetch failure just means + // the setup screen falls back to name-only cards. + this.oppById = {}; + fetch('data/opponents.json') + .then((r) => r.json()) + .then((d) => { for (const o of d.opponents ?? []) this.oppById[o.id] = o; }) + .catch(() => { /* portraits are optional */ }); + + this.events.once('shutdown', () => this._teardown()); + this.showMenu(); + } + + // ------------------------------------------------------------------------- + // Screen routing + // ------------------------------------------------------------------------- + + swapScreen(next) { + this.screen?.destroy(); + this.screen = next ?? null; + } + + showMenu() { + this.phase = 'menu'; + this._endMatch(); + this.swapScreen(Screens.mainMenu(this, { + hasSave: !!localStorage.getItem(SAVE_KEY), + onSkirmish: () => this.showSkirmishSetup(), + onCampaign: () => this.showCampaign(), + onContinue: () => this.loadSave(), + onLeave: () => this.scene.start('GameMenu'), + })); + } + + showSkirmishSetup() { + this.phase = 'setup'; + this.swapScreen(Screens.skirmishSetup(this, { + onBack: () => this.showMenu(), + onStart: (cfg) => this.startSkirmish(cfg), + })); + } + + showCampaign() { + this.phase = 'setup'; + api.get(`/puzzles/${this.gameDef.slug}/progress`) + .then((p) => p?.levelsCompleted ?? 0) + .catch(() => 0) + .then((cleared) => { + this.swapScreen(Screens.campaignList(this, this.campaign, cleared, { + onBack: () => this.showMenu(), + onPick: (idx) => this.startMission(idx), + })); + }); + } + + // ------------------------------------------------------------------------- + // Match lifecycle + // ------------------------------------------------------------------------- + + startSkirmish(cfg) { + const map = generateMap(this.rules, { + seed: cfg.seed, size: cfg.size, theme: cfg.theme, symmetry: cfg.symmetry, + armies: cfg.armies.length, + }); + const state = Logic.createMatch(this.rules, { + seed: cfg.seed, map, + armies: cfg.armies.map((a, i) => ({ + armyId: a.armyId, commanderId: a.commanderId, + isHuman: i === 0, aiSkill: a.skill, + aiProfile: this.rules.commanderById?.[a.commanderId]?.aiProfile ?? null, + })), + }); + this.meta = { mode: 'skirmish', cfg }; + this._beginMatch(state, 0); + } + + startMission(idx) { + const m = this.campaign.missions[idx]; + if (!m) return this.showMenu(); + const map = decodeMap(this.rules, m.map); + const state = Logic.createMatch(this.rules, { + seed: m.seed ?? 1234, map, + armies: [ + { armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true }, + ...(m.enemies ?? []).map((e) => ({ + armyId: e.army, commanderId: e.commander, + aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null, + })), + ], + }); + (m.startResources ?? []).forEach((r, i) => { + if (!state.armies[i]) return; + state.armies[i].mass = r.mass ?? state.armies[i].mass; + state.armies[i].energy = r.energy ?? state.armies[i].energy; + }); + this.meta = { mode: 'campaign', missionIdx: idx }; + this.mission = m; + this._beginMatch(state, 0); + } + + _beginMatch(state, playerArmy) { + this.swapScreen(null); + this.match = state; + this.playerArmy = playerArmy; + this.selection.clear(); + this.groups.clear(); + this.placement = null; + + this.view = new TAWorldView(this, this.rules, this.art, state, playerArmy); + this.view.setFogEnabled(this.settings.fog !== false); + this.fx = new TAFx(this, this.view.worldRoot, DEPTHS); + this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, { + onBuildPick: (def) => this._beginPlacement(def), + onProduce: (def, n) => this._enqueue(def, n), + onMinimapJump: (x, y) => this.view.centerOn(x, y), + }); + if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs); + + const start = state.starts.find((s) => s.army === playerArmy); + if (start) this.view.centerOn(start.x * state.tileSize, start.y * state.tileSize); + + this.phase = 'playing'; + this._lastAutosave = this.time.now; + } + + _endMatch() { + this.hud?.destroy(); this.hud = null; + this.fx?.destroy(); this.fx = null; + this.view?.destroy(); this.view = null; + this.match = null; + this.selection.clear(); + } + + _teardown() { + this._endMatch(); + this.screen?.destroy(); + this.music?.destroy?.(); + } + + // ------------------------------------------------------------------------- + // The loop + // ------------------------------------------------------------------------- + + update(time, delta) { + if (this.phase !== 'playing' || !this.match) return; + const st = this.match; + + this._updateCamera(delta); + + // The AI thinks on sim ticks, not render frames, so it is stepped inside the same + // fixed-step loop the simulation uses — otherwise its cadence would ride framerate. + const before = st.tick; + const events = Logic.step(st, this.rules, delta * this.simSpeed, 4, (s) => { + for (const a of s.armies) { + if (!a.alive || a.isHuman) continue; + runAI(this.rules, s, a.idx, { skill: a.aiSkill, ...(a.aiProfile ?? {}) }); + } + }); + if (st.tick !== before) this.view.fogDirty = true; + + for (const ev of events) this._onSimEvent(ev); + + const { nanoLinks, projectiles } = this.view.render(st.alpha); + this.fx.draw(delta, nanoLinks, projectiles, time); + this._syncSelection(); + this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null); + this._drawDragBox(); + + if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) { + this._lastAutosave = time; + this.saveGame(); + } + if (st.over) this._finish(st.over); + } + + _onSimEvent(ev) { + this.fx.onEvent(ev, this.rules); + if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) { + // Terrain under a finished structure changes, so its chunk has to be restamped. + this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128); + } + if (ev.t === 'armyEliminated' && ev.army !== this.playerArmy) { + this.hud.toast(`${this.rules.armies[ev.army]?.name ?? 'Enemy'} eliminated`, '#9ce6a0'); + } + if (ev.t === 'noPath' && ev.army === this.playerArmy) { + this.hud.toast('No route there', '#ff9a6b'); + } + } + + _finish(over) { + this.phase = 'result'; + const won = over.winner === this.playerArmy; + api.post('/history/single-player', { + slug: this.gameDef.slug, score: Math.round(this.match.elapsedSec), + opponentScores: [], result: won ? 'win' : 'loss', + }).catch(() => { /* best effort */ }); + + if (won && this.meta?.mode === 'campaign') { + api.post(`/puzzles/${this.gameDef.slug}/complete`, { level: this.meta.missionIdx + 1 }) + .catch(() => { /* best effort */ }); + } + localStorage.removeItem(SAVE_KEY); + + const stats = summarise(this.match, this.playerArmy); + this.swapScreen(Screens.results(this, { won, stats, elapsed: this.match.elapsedSec }, { + onMenu: () => this.showMenu(), + onAgain: () => { + if (this.meta?.mode === 'campaign') this.startMission(this.meta.missionIdx); + else this.startSkirmish({ ...this.meta.cfg, seed: (this.meta.cfg.seed * 7919 + 13) >>> 0 }); + }, + })); + } + + // ------------------------------------------------------------------------- + // Camera + // ------------------------------------------------------------------------- + + _updateCamera(delta) { + const k = this.keys; + const dt = delta / 1000; + let dx = 0, dy = 0; + if (k.A.isDown || k.LEFT.isDown) dx -= 1; + if (k.D.isDown || k.RIGHT.isDown) dx += 1; + if (k.W.isDown || k.UP.isDown) dy -= 1; + if (k.S.isDown || k.DOWN.isDown) dy += 1; + + // Edge scroll stays off until the mouse has actually moved over the canvas. Phaser's + // activePointer sits at (0,0) until its first event, which is inside the top-left edge + // band — so without this the camera silently flies off the corner of the map the moment + // a match starts, taking the player's Commander off screen before they touch anything. + if (this.settings.edgeScroll !== false && this._pointerLive && !this._dragging) { + const p = this.input.activePointer; + if (p.x >= 0 && p.y >= 0 && p.x <= GAME_WIDTH && p.y <= GAME_HEIGHT) { + if (p.x < EDGE_PAN) dx -= 1; + if (p.x > GAME_WIDTH - EDGE_PAN) dx += 1; + if (p.y < EDGE_PAN) dy -= 1; + if (p.y > GAME_HEIGHT - EDGE_PAN) dy += 1; + } + } + if (dx || dy) { + const len = Math.hypot(dx, dy) || 1; + this.view.panBy((dx / len) * PAN_SPEED * dt, (dy / len) * PAN_SPEED * dt); + } + } + + // ------------------------------------------------------------------------- + // Input + // ------------------------------------------------------------------------- + + _bindInput() { + this.input.on('pointermove', () => { this._pointerLive = true; }); + this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); }); + this.input.on('pointerup', (p) => this._onPointerUp(p)); + this.input.on('wheel', (p, objs, dx, dy) => { + if (this.phase !== 'playing') return; + this.view.zoomBy(dy > 0 ? -1 : 1, p.x, p.y); + }); + + this.input.keyboard.on('keydown', (ev) => { + if (this.phase === 'playing') this._onKey(ev); + }); + } + + _onKey(ev) { + const code = ev.key?.toLowerCase(); + if (ev.code === 'Escape') { + if (this.placement) { this._cancelPlacement(); return; } + if (this.pendingCommand) { this.pendingCommand = null; return; } + this.togglePause(); + return; + } + // Control groups: Ctrl+N assigns, N recalls, N again centres on the group. + if (/^[1-9]$/.test(code)) { + const n = Number(code); + if (ev.ctrlKey) { + this.groups.set(n, [...this.selection]); + this.hud.toast(`Group ${n} set (${this.selection.size})`); + } else { + const ids = (this.groups.get(n) ?? []).filter((id) => Logic.entityById(this.match, id)); + if (!ids.length) return; + const same = ids.length === this.selection.size && ids.every((id) => this.selection.has(id)); + this.selection = new Set(ids); + if (same) { + const e = Logic.entityById(this.match, ids[0]); + if (e) this.view.centerOn(e.x, e.y); + } + } + return; + } + switch (code) { + case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break; + case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break; + case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break; + case 'x': this._order({ type: 'stop' }); break; + case 'h': this._order({ type: 'hold' }); break; + case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break; + case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break; + case ' ': this._jumpToAction(); break; + default: break; + } + if (ev.ctrlKey && code === 'a') this._selectAll(); + } + + _onPointerDown(p) { + if (this.phase !== 'playing') return; + if (this.hud.hitsUi(p.x, p.y)) return; + + if (p.middleButtonDown()) { this._panAnchor = { x: p.x, y: p.y }; return; } + + const w = this.view.worldPoint(p.x, p.y); + if (p.rightButtonDown()) { this._issueContextual(w, p.event?.shiftKey); return; } + + if (this.placement) { this._commitPlacement(w, p.event?.shiftKey); return; } + if (this.pendingCommand) { this._applyPendingCommand(w, p.event?.shiftKey); return; } + + this._dragStart = { x: p.x, y: p.y, wx: w.x, wy: w.y, add: !!p.event?.shiftKey }; + this._dragging = false; + } + + _onPointerUp(p) { + this._panAnchor = null; + if (this.phase !== 'playing' || !this._dragStart) return; + const d = this._dragStart; + this._dragStart = null; + const w = this.view.worldPoint(p.x, p.y); + if (this._dragging) this._selectInBox(d.wx, d.wy, w.x, w.y, d.add); + else this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1); + this._dragging = false; + } + + _drawDragBox() { + if (!this._dragBoxG) this._dragBoxG = this.add.graphics().setDepth(2000); + const g = this._dragBoxG; + g.clear(); + if (!this._dragStart) return; + const p = this.input.activePointer; + if (Math.abs(p.x - this._dragStart.x) + Math.abs(p.y - this._dragStart.y) > DRAG_MIN) this._dragging = true; + if (!this._dragging) return; + this.view.uiRoot.add(g); + const x = Math.min(p.x, this._dragStart.x), y = Math.min(p.y, this._dragStart.y); + const w = Math.abs(p.x - this._dragStart.x), h = Math.abs(p.y - this._dragStart.y); + g.fillStyle(0x7dff9b, 0.10); g.fillRect(x, y, w, h); + g.lineStyle(1.5, 0x7dff9b, 0.9); g.strokeRect(x, y, w, h); + + if (this._panAnchor) { + this.view.panBy(this._panAnchor.x - p.x, this._panAnchor.y - p.y); + this._panAnchor = { x: p.x, y: p.y }; + } + } + + // ------------------------------------------------------------------------- + // Selection + // ------------------------------------------------------------------------- + + _mine() { + return this.match.entities.filter((e) => !e.dead && e.army === this.playerArmy); + } + + _selectedEntities() { + return [...this.selection].map((id) => Logic.entityById(this.match, id)).filter(Boolean); + } + + /** Drop dead entities so the HUD and orders never reference a corpse. */ + _syncSelection() { + for (const id of [...this.selection]) { + const e = Logic.entityById(this.match, id); + if (!e || e.dead) this.selection.delete(id); + } + this.view.selection = this.selection; + } + + _selectAt(x, y, add, double) { + let best = null, bestD = Infinity; + for (const e of this.match.entities) { + if (e.dead) continue; + if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue; + const d = Math.hypot(e.x - x, e.y - y); + const r = e.isBuilding + ? Math.max(e.radius, Math.max(...Object.values(this.rules.defById[e.defId].footprint)) * this.match.tileSize * 0.5) + : e.radius + 6; + if (d <= r && d < bestD) { best = e; bestD = d; } + } + if (!best) { if (!add) this.selection.clear(); return; } + if (!add) this.selection.clear(); + if (double && best.army === this.playerArmy) { + // Double-click grabs every on-screen unit of the same type — the standard RTS idiom. + const cam = this.cameras.main.worldView; + for (const e of this._mine()) { + if (e.defId === best.defId && Phaser.Geom.Rectangle.Contains(cam, e.x, e.y)) this.selection.add(e.id); + } + return; + } + this.selection.add(best.id); + } + + _selectInBox(x0, y0, x1, y1, add) { + if (!add) this.selection.clear(); + const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }; + const hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) }; + // A box prefers mobile units: dragging over a base to grab tanks shouldn't also + // select the factory they came out of. + const hits = this._mine().filter((e) => e.x >= lo.x && e.x <= hi.x && e.y >= lo.y && e.y <= hi.y); + const mobile = hits.filter((e) => !e.isBuilding && !e.site); + for (const e of (mobile.length ? mobile : hits)) this.selection.add(e.id); + } + + _selectAll() { + this.selection.clear(); + for (const e of this._mine()) if (!e.isBuilding && !e.site) this.selection.add(e.id); + } + + _jumpToAction() { + const e = this._mine().find((u) => u.targetId) ?? this._mine()[0]; + if (e) this.view.centerOn(e.x, e.y); + } + + // ------------------------------------------------------------------------- + // Orders + // ------------------------------------------------------------------------- + + _order(order, queue = false) { + const unitIds = [...this.selection]; + if (!unitIds.length) return { ok: false }; + const r = Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, unitIds, order, queue, + }); + if (!r.ok && r.error) this.hud.toast(r.error, '#ff9a6b'); + return r; + } + + /** Right-click: move, attack an enemy, or set a factory's rally point. */ + _issueContextual(w, queue) { + const sel = this._selectedEntities(); + if (!sel.length) return; + + const factories = sel.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); + if (factories.length === sel.length) { + for (const f of factories) { + Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y }, + }); + } + this.hud.toast('Rally point set'); + return; + } + + const target = this._entityAt(w.x, w.y); + if (target && target.army !== this.playerArmy && this.view.visibleToPlayer(target)) { + this._order({ type: 'attack', targetId: target.id }, queue); + return; + } + if (target && target.army === this.playerArmy && target.site) { + this._order({ type: 'assist', targetId: target.id }, queue); + return; + } + this._order({ type: 'move', x: w.x, y: w.y }, queue); + } + + _applyPendingCommand(w, queue) { + const cmd = this.pendingCommand; + this.pendingCommand = null; + if (cmd === 'guard') { + const t = this._entityAt(w.x, w.y); + if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue); + return; + } + this._order({ type: cmd, x: w.x, y: w.y }, queue); + } + + _entityAt(x, y) { + let best = null, bestD = Infinity; + for (const e of this.match.entities) { + if (e.dead) continue; + const d = Math.hypot(e.x - x, e.y - y); + if (d <= e.radius + 8 && d < bestD) { best = e; bestD = d; } + } + return best; + } + + _enqueue(def, count) { + const f = this._selectedEntities().find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.includes(def.id)); + if (!f) return; + Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, + order: { type: 'factoryEnqueue', factoryId: f.id, defId: def.id, count }, + }); + } + + // ------------------------------------------------------------------------- + // Building placement + // ------------------------------------------------------------------------- + + _beginPlacement(def) { + const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id)); + if (!builder) return; + this.placement = { def, builderId: builder.id }; + this.view.highlightMassSpots(!!def.terrainMultiplier); + this.input.on('pointermove', this._onPlacementMove, this); + this._onPlacementMove(this.input.activePointer); + } + + _onPlacementMove(p) { + if (!this.placement) return; + const w = this.view.worldPoint(p.x, p.y); + const ts = this.match.tileSize; + const def = this.placement.def; + // Centre the footprint on the cursor, then snap to the tile grid. + const tx = Math.round(w.x / ts - def.footprint.w / 2); + const ty = Math.round(w.y / ts - def.footprint.h / 2); + const builder = Logic.entityById(this.match, this.placement.builderId); + this.placement.tx = tx; this.placement.ty = ty; + this.view.placement = { + def, tx, ty, + legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def), + builderX: builder?.x, builderY: builder?.y, + buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0, + }; + } + + _commitPlacement(w, queue) { + const p = this.placement; + if (!p) return; + const r = Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, unitIds: [p.builderId], + order: { type: 'build', defId: p.def.id, tx: p.tx, ty: p.ty }, + queue, + }); + if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; } + // Shift keeps the cursor loaded so a row of generators is one gesture. + if (!queue) this._cancelPlacement(); + } + + _cancelPlacement() { + this.placement = null; + this.view.placement = null; + this.view.highlightMassSpots(false); + this.input.off('pointermove', this._onPlacementMove, this); + } + + // ------------------------------------------------------------------------- + // Pause / persistence + // ------------------------------------------------------------------------- + + togglePause() { + if (this.phase === 'playing') { + this.phase = 'paused'; + this.swapScreen(Screens.pause(this, { + settings: this.settings, + onResume: () => { this.phase = 'playing'; this.swapScreen(null); }, + onSave: () => { this.saveGame(); this.hud.toast('Saved'); }, + onQuit: () => this.showMenu(), + onToggleFog: (on) => { + this.settings.fog = on; + writeJson(SETTINGS_KEY, this.settings); + this.view.setFogEnabled(on); + }, + onToggleEdge: (on) => { this.settings.edgeScroll = on; writeJson(SETTINGS_KEY, this.settings); }, + onSpeed: (v) => { this.simSpeed = v; }, + })); + } else if (this.phase === 'paused') { + this.phase = 'playing'; + this.swapScreen(null); + } + } + + saveGame() { + if (!this.match) return; + try { + localStorage.setItem(SAVE_KEY, JSON.stringify({ + v: Logic.SAVE_VERSION, meta: this.meta, state: Logic.serialize(this.match), + })); + } catch (_) { /* quota — a failed autosave must not kill the match */ } + } + + loadSave() { + const raw = readJson(SAVE_KEY, null); + if (!raw || raw.v !== Logic.SAVE_VERSION) { this.hud?.toast('No compatible save'); return; } + const state = Logic.deserialize(this.rules, raw.state); + this.meta = raw.meta; + if (raw.meta?.mode === 'campaign') this.mission = this.campaign.missions[raw.meta.missionIdx]; + this._beginMatch(state, 0); + } +} + +function summarise(state, armyIdx) { + const a = state.armies[armyIdx]; + return { + built: a.builtEver, lost: a.lostEver, kills: a.killsEver, + mass: Math.round(a.mass), energy: Math.round(a.energy), + }; +} + +function readJson(key, fallback) { + try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (_) { return fallback; } +} + +function writeJson(key, v) { + try { localStorage.setItem(key, JSON.stringify(v)); } catch (_) { /* ignore */ } +} diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md new file mode 100644 index 0000000..c7a2aac --- /dev/null +++ b/src/games/totalannihilation/sprites.md @@ -0,0 +1,173 @@ +# Total Annihilation — art spec + +Every sheet is **optional**. `data/totalannihilation-artwork.json` ships with `path: null` on +all of them, and `TAArt.js` paints a procedural stand-in at runtime, so the game is fully +playable with zero PNG files. Drop a sheet in and it replaces the stand-in with no code change. + +To install a sheet: save the PNG under `assets/images/totalannihilation/`, then set that +sheet's `path` in `data/totalannihilation-artwork.json`. Nothing else needs editing — the +asset manifest resolves every entry in the `sheets` map automatically +(`src/data/assetManifest.js`), so **new sheets never need a manifest change either**. + +--- + +## The two rules that make the art cheap + +1. **Units are drawn once, facing right.** `0 rad` is Phaser's `+x` axis, and the renderer + calls `setRotation(heading)` — so a unit needs exactly **one** frame, not eight facings. + Draw it pointing **right**, dead centre in its cell. +2. **Units are drawn in neutral grey.** The renderer applies `setTint(armyColour)` per army. + Paint in greys with the detail carried by *value* (light/dark), not hue; any hue you paint + is multiplied by the team colour and will read as muddy. Keep the brightest highlight near + white so the tint stays vivid. + +Terrain is exempt from both: tiles are drawn in full colour and are never rotated or tinted. + +--- + +## Sheets + +| Sheet name (JSON key) | Texture key | Cell | Cols | Contents | +|---|---|---|---|---| +| `arm-units` | `ta-arm-units` | 64×64 | 8 | ARM mobile units | +| `core-units` | `ta-core-units` | 64×64 | 8 | CORE mobile units | +| `arm-structures` | `ta-arm-structures` | 128×128 | 6 | ARM buildings | +| `core-structures` | `ta-core-structures` | 128×128 | 6 | CORE buildings | +| `terrain-grasslands` | `ta-terrain-grasslands` | 64×64 | 9 | Grassland tiles | +| `terrain-snowfields` | `ta-terrain-snowfields` | 64×64 | 9 | Snow tiles | +| `terrain-tropics` | `ta-terrain-tropics` | 64×64 | 9 | Jungle tiles | +| `icons` | `ta-icons` | 44×44 | 10 | Command/build glyphs | + +Frames are numbered **left to right, then top to bottom**, starting at 0. + +--- + +## Unit sheets (64×64 cells, 8 per row) + +Both army sheets use the identical frame layout — that is what lets one unit definition serve +every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and +let the scale do the work. + +| Frame | Unit | Drawn as | `spritePx` | +|---|---|---|---| +| 0 | Commander | hull / legs, no weapon | 56 | +| 1 | Commander turret | upper body + D-Gun arm | 56 | +| 2 | Infantry | whole figure, top-down | 30 | +| 3 | Sniper | whole figure, long rifle | 30 | +| 4 | Jeep | chassis + wheels | 40 | +| 5 | Jeep turret | pintle gun | 40 | +| 6 | Tank | hull + tracks | 54 | +| 7 | Tank turret | turret + barrel | 54 | +| 8 | Rocket Tank | hull + tracks | 56 | +| 9 | Rocket Tank turret | rocket pod | 56 | + +**Turret frames** are drawn as a separate image stacked on the hull and rotated +independently, so the unit aims while it drives. Rules: + +- The turret's **pivot is the centre of the cell** — draw the turret so its rotation point + sits at (32, 32), with the barrel extending to the **right**. +- Leave the rest of the turret cell transparent. The hull shows through it. +- A unit with no turret (infantry, sniper) simply has no turret frame; its whole sprite + rotates to face its target. + +Frames 10–63 are free. Add a unit by appending a definition to `units[]` in +`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code. + +--- + +## Structure sheets (128×128 cells, 6 per row) + +Buildings do **not** rotate. They are scaled to `footprint × 64px`, so a 2×2 building renders +at 128px and a 3×3 at 192px — draw the 3×3 buildings to fill the cell and accept that they are +scaled up 1.5×, or supply a larger cell size in the artwork JSON. + +Each building has **two** frames: the finished structure, and a **build frame** shown while it +is under construction (the renderer also fades it in by build progress). Draw the build frame +as a skeleton/scaffold version — girders, no panels, no lights. + +| Frame | Building | Footprint | Notes | +|---|---|---|---| +| 0 | Energy Generator | 2×2 | radiating fins / vents | +| 1 | Energy Generator — under construction | 2×2 | | +| 2 | Mass Generator | 2×2 | drill ring; sits on metal patches | +| 3 | Mass Generator — under construction | 2×2 | | +| 4 | Barracks | 3×3 | doorway on the **bottom** edge (units exit downward) | +| 5 | Barracks — under construction | 3×3 | | +| 6 | Vehicle Plant | 3×3 | wide bay door on the **bottom** edge | +| 7 | Vehicle Plant — under construction | 3×3 | | + +Frames 8–35 are free. + +--- + +## Terrain sheets (64×64 cells, 9 per row) + +**Every `kind: "terrain"` sheet must follow this exact frame order** — it is fixed in +`terrainFrames` in the artwork JSON, which is what lets a new theme be a pure drop-in: + +| Frame | Tile | Notes | +|---|---|---| +| 0 | ground | the base tile — most of the map | +| 1 | groundAlt1 | variation, scattered to break up tiling | +| 2 | groundAlt2 | second variation | +| 3 | rough | slows wheeled units; still buildable | +| 4 | cliff | impassable, not buildable | +| 5 | water | impassable to current units | +| 6 | waterDeep | impassable; used for the interior of large bodies | +| 7 | metal | metal patch — **doubles a Mass Generator's yield** | +| 8 | road | fast movement | + +Tiles must be **seamlessly tileable on all four edges**, since the terrain is stamped into +chunked RenderTextures with no blending. Keep them low-contrast: units and health bars are +drawn on top, and busy terrain makes a battle unreadable. + +Metal (frame 7) should read as *obviously* valuable at a glance — it is the one tile the +player hunts for. + +### Adding a new theme + +1. Draw a 9-frame terrain sheet in the order above. +2. Add it to `sheets` in `data/totalannihilation-artwork.json` with `"kind": "terrain"`. +3. Add an entry to `themes` naming that sheet, plus a `palette` giving a colour per terrain + id (`ground`, `rough`, `cliff`, `water`, `metal`, `road`, …). The palette drives the + minimap and the procedural fallback, so it is required even when a real sheet exists. + +The theme then appears in the skirmish setup screen automatically. + +--- + +## Icons (44×44 cells, 10 per row) + +Command glyphs for the HUD: move, attack, attack-move, stop, hold, patrol, guard, build, +repair, rally. Purely decorative today — the build menu labels itself from unit names — so +this sheet is the lowest priority of the eight. + +--- + +## Adding a third army (no code) + +Append two sheets (`kind: "unit"` and `kind: "structure"`) to the artwork JSON, then add a row +to `armies[]` in the rules JSON naming them plus a colour: + +```json +{ "id": "hive", "name": "HIVE", "color": "#8ad46a", + "unitSheet": "hive-units", "structureSheet": "hive-structures" } +``` + +Every existing unit and building definition works for the new army unchanged, because each +definition references a **sheet slot** (`unitSheet` / `structureSheet`) rather than a sheet +name. Add commanders for it in `commanders[]`, pointing `opponentId` at an entry in +`data/opponents.json` for the portrait. + +--- + +## Checking your work + +``` +node tools/verifyTotalAnnihilation.js --quick +``` + +Section 2 asserts that every declared sheet has a key and frame size, that each army's sheets +exist, that every theme has a palette entry for every terrain type, and that no unit or +building references a frame index beyond its sheet's `cols × rows` capacity — so a +mis-numbered frame fails the build instead of rendering the wrong tank in a battle. diff --git a/src/main.js b/src/main.js index d4f08e2..138a143 100644 --- a/src/main.js +++ b/src/main.js @@ -97,6 +97,7 @@ import SuperKartGame from './games/superkart/SuperKartGame.js'; import SuperKartEditor from './games/superkart/SuperKartEditor.js'; import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js'; import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js'; +import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGame.js'; const config = { type: Phaser.AUTO, @@ -207,6 +208,7 @@ const config = { SuperKartEditor, AdvanceWarsGame, TetrisAttackGame, + TotalAnnihilationGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index fdf171f..d42db29 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -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', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame' }; + 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', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index bf65c8e..043f131 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -49,6 +49,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('adventure-music', 'data/adventure-music.json'); this.load.json('advancewars-artwork', 'data/advancewars-artwork.json'); this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json'); + this.load.json('totalannihilation-artwork', 'data/totalannihilation-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'); diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 7f41226..4644772 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -20,6 +20,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { superkart: 'nintendo', advancewars: 'nintendo', tetrisattack: 'nintendo', + totalannihilation: 'hacker', coloradodefense: 'arcadedark', tempest: 'arcadedark', mastermind: 'hacker', diff --git a/tools/genTACampaign.js b/tools/genTACampaign.js new file mode 100644 index 0000000..74ce870 --- /dev/null +++ b/tools/genTACampaign.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Emits data/totalannihilation-campaign.json. +// +// Mission terrain is baked from the seeded generator at authoring time and frozen into the +// file as plain row strings — after that it is ordinary hand-editable map data, and the +// briefings, prebuilt bases, enemy skills and objectives below are hand-authored. Re-running +// this regenerates the terrain for the recorded seeds; editing the JSON by hand afterwards is +// entirely fine and will simply be overwritten if you run it again. +// +// node tools/genTACampaign.js + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { compileRules } from '../src/games/totalannihilation/TARules.js'; +import { generateMap } from '../src/games/totalannihilation/TAMapGen.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const rules = compileRules(JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'))); +const CH = rules.terrain.map((t) => t.ch); + +// Hand-authored mission table. `seed`/`size`/`symmetry` pick the battlefield; everything +// else is the mission design proper. +// +// The seeds are not arbitrary. Even a mirrored map can hand one start a decisive positional +// edge (metal spread, chokes, how much room the base has to grow), and across candidate seeds +// the same mission swung between unwinnable and a walkover. Every seed here was chosen by +// running a skill-5 bot on the player's side — the same assertion tools/ +// verifyTotalAnnihilation.js §13 makes, so a bad seed fails the build rather than shipping. +const MISSIONS = [ + { + id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', + enemy: { commander: 'klaxon', skill: 1, aggression: 0.25 }, + startResources: [{ mass: 1600, energy: 2600 }, { mass: 600, energy: 900 }], + prebuilt: [], + briefing: 'Establish a base and remove the CORE presence. Power first — nothing moves without it.', + victory: 'Beachhead secured. The corridor inland is open.', + defeat: 'The landing has been thrown back into the sea.', + }, + { + id: 'm02', name: 'Metal Fields', seed: 20773, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', + enemy: { commander: 'klaxon', skill: 2, aggression: 0.4 }, + startResources: [{ mass: 1300, energy: 2200 }, { mass: 900, energy: 1400 }], + prebuilt: [{ enemy: true, id: 'energygen', dx: 3, dy: -2 }], + briefing: 'Metal patches double a generator\'s yield. Take them before CORE does.', + victory: 'The patches are ours. Production doubles from here.', + defeat: 'CORE holds the metal. We cannot match their output.', + }, + { + id: 'm03', name: 'Cold Open', seed: 31287, size: 'small', theme: 'snowfields', symmetry: 'mirror-y', + enemy: { commander: 'cybro', skill: 2, aggression: 0.6 }, + startResources: [{ mass: 1200, energy: 2000 }, { mass: 1100, energy: 1800 }], + prebuilt: [{ id: 'energygen', dx: 3, dy: -2 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 }], + briefing: 'Cy-Bro opens with infantry. Armour answers infantry — get a Vehicle Plant up early.', + victory: 'Cy-Bro\'s foothold is slag.', + defeat: 'Overrun before the plant finished.', + }, + { + id: 'm04', name: 'The Long Ridge', seed: 44377, size: 'medium', theme: 'grasslands', symmetry: 'rotational', + enemy: { commander: 'klaxon', skill: 3, aggression: 0.5 }, + startResources: [{ mass: 2200, energy: 3600 }, { mass: 1300, energy: 2200 }], + prebuilt: [{ id: 'energygen', dx: 3, dy: -2 }, { id: 'energygen', dx: -3, dy: -2 }], + briefing: 'A fair fight on a wide map. Expand hard — whoever out-earns the other wins the third engagement.', + victory: 'The ridge is held.', + defeat: 'KLAXON out-produced us and it showed.', + }, + { + id: 'm05', name: 'Tropic Vice', seed: 55930, size: 'medium', theme: 'tropics', symmetry: 'mirror-x', + enemy: { commander: 'cybro', skill: 4, aggression: 0.7 }, + startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }], + prebuilt: [ + { id: 'energygen', dx: 3, dy: -2 }, { id: 'energygen', dx: -3, dy: -2 }, + { id: 'barracks', dx: 4, dy: 3 }, + { enemy: true, id: 'energygen', dx: 3, dy: 2 }, + ], + briefing: 'Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields.', + victory: 'Cy-Bro is scrap on the sand.', + defeat: 'The push came faster than the plant did.', + }, + { + id: 'm06', name: 'Annihilation', seed: 63541, size: 'medium', theme: 'snowfields', symmetry: 'rotational', + enemy: { commander: 'klaxon', skill: 5, aggression: 0.85 }, + startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }], + prebuilt: [ + { id: 'energygen', dx: 3, dy: -3 }, { id: 'energygen', dx: -3, dy: -3 }, + { id: 'barracks', dx: 4, dy: 3 }, { id: 'vehicleplant', dx: -4, dy: 3 }, + { enemy: true, id: 'energygen', dx: 3, dy: -3 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 }, + ], + briefing: 'KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it.', + victory: 'Total annihilation. There is nothing left to fight.', + defeat: 'KLAXON was simply better prepared.', + }, +]; + +function rowsFor(map) { + const rows = []; + for (let y = 0; y < map.h; y++) { + let s = ''; + for (let x = 0; x < map.w; x++) s += CH[map.terrain[y * map.w + x]]; + rows.push(s); + } + return rows; +} + +const missions = MISSIONS.map((m) => { + const map = generateMap(rules, { seed: m.seed, size: m.size, theme: m.theme, symmetry: m.symmetry, armies: 2 }); + const starts = map.starts.map((s) => ({ army: s.army, x: s.x, y: s.y })); + const enemyStart = starts.find((s) => s.army === 1) ?? starts[0]; + const playerCmd = rules.commanders.find((c) => c.armyId === 'arm'); + const enemyCmd = rules.commanderById[m.enemy.commander]; + + const playerStart = starts.find((s) => s.army === 0) ?? starts[0]; + const buildings = (m.prebuilt ?? []).map((b) => { + const anchor = b.enemy ? enemyStart : playerStart; + return { + army: b.enemy ? 1 : 0, type: b.id, + tx: Math.max(1, Math.min(map.w - 4, anchor.x + b.dx)), + ty: Math.max(1, Math.min(map.h - 4, anchor.y + b.dy)), + }; + }); + + return { + id: m.id, name: m.name, theme: m.theme, seed: m.seed, + playerArmy: 'arm', playerCommander: playerCmd.id, + enemies: [{ + army: enemyCmd.armyId, commander: enemyCmd.id, + aiProfile: { skill: m.enemy.skill, aggression: m.enemy.aggression }, + }], + startResources: m.startResources, + objective: { type: 'destroyAll' }, + briefing: [{ speaker: playerCmd.opponentId, mood: 'idle', text: m.briefing }], + victoryLine: { speaker: playerCmd.opponentId, mood: 'happy', text: m.victory }, + defeatLine: { speaker: enemyCmd.opponentId, mood: 'smug', text: m.defeat }, + map: { w: map.w, h: map.h, theme: m.theme, rows: rowsFor(map), starts, buildings }, + }; +}); + +const out = { _comment: 'Generated by tools/genTACampaign.js — terrain is baked, design is hand-authored.', missions }; +writeFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), `${JSON.stringify(out, null, 1)}\n`); +console.log(`wrote ${missions.length} missions`); +for (const m of missions) console.log(` ${m.id} ${m.name.padEnd(16)} ${m.map.w}x${m.map.h} skill ${m.enemies[0].aiProfile.skill}`); diff --git a/tools/lib/taStubScene.js b/tools/lib/taStubScene.js new file mode 100644 index 0000000..d4e75d9 --- /dev/null +++ b/tools/lib/taStubScene.js @@ -0,0 +1,134 @@ +// Minimal Phaser stand-in for tools/verifyTotalAnnihilation.js. +// +// TAWorldView, TAFx and TAArt deliberately import no Phaser, so the real render path can be +// exercised in Node against this. It models the behaviour that matters and NOT the behaviour +// that would hide bugs — in particular a Container here draws in insertion order and only +// reorders when sort() is called, exactly like the real thing. +export function makeStubScene(w = 1920, h = 1080) { + const created = []; + const track = (o) => { created.push(o); return o; }; + const base = (type, extra = {}) => { + const o = { + type, x: 0, y: 0, depth: 0, visible: true, alpha: 1, rotation: 0, + scaleX: 1, scaleY: 1, displayWidth: 0, displayHeight: 0, tint: null, + frame: null, texture: null, parent: null, + setPosition(x, y) { this.x = x; this.y = y; return this; }, + setOrigin() { return this; }, + setDepth(d) { this.depth = d; return this; }, + setVisible(v) { this.visible = v; return this; }, + setAlpha(a) { this.alpha = a; return this; }, + setRotation(r) { this.rotation = r; return this; }, + setScale(s) { this.scaleX = this.scaleY = s; return this; }, + setDisplaySize(dw, dh) { this.displayWidth = dw; this.displayHeight = dh; return this; }, + setTint(t) { this.tint = t; return this; }, + setTexture(k) { this.texture = k; return this; }, + setFrame(f) { this.frame = f; return this; }, + setInteractive() { return this; }, + setSize() { return this; }, + setStrokeStyle() { return this; }, + setFillStyle() { return this; }, + on() { return this; }, + destroy() { this.destroyed = true; }, + ...extra, + }; + return track(o); + }; + const gfx = () => base('graphics', { + ops: 0, + clear() { this.ops = 0; return this; }, + lineStyle() { return this; }, fillStyle() { return this; }, + beginPath() { return this; }, closePath() { return this; }, + moveTo() { return this; }, lineTo() { return this; }, + fillPath() { return this; }, strokePath() { return this; }, + fillRect() { this.ops++; return this; }, strokeRect() { this.ops++; return this; }, + fillCircle() { this.ops++; return this; }, strokeCircle() { this.ops++; return this; }, + fillEllipse() { this.ops++; return this; }, strokeEllipse() { this.ops++; return this; }, + lineBetween() { this.ops++; return this; }, fillTriangle() { this.ops++; return this; }, + fillRoundedRect() { this.ops++; return this; }, strokeRoundedRect() { this.ops++; return this; }, + }); + const container = () => base('container', { + list: [], + add(o) { for (const c of [].concat(o)) { if (c) { c.parent = this; this.list.push(c); } } return this; }, + remove() { return this; }, + // Phaser Containers draw in INSERTION order and only reorder when sort() is called, + // so the stub must not quietly sort for us or it hides exactly the bug we're hunting. + sort(prop) { this.list = this.list.slice().sort((a, b) => (a[prop] ?? 0) - (b[prop] ?? 0)); return this; }, + }); + const ctxStub = new Proxy({}, { + get: (_t, p) => { + if (p === 'createImageData') return (w2, h2) => ({ data: new Uint8ClampedArray(w2 * h2 * 4) }); + if (p === 'getImageData') return (x, y, w2, h2) => ({ data: new Uint8ClampedArray(w2 * h2 * 4) }); + return () => {}; + }, + set: () => true, + }); + const textures = new Map(); + // Camera maths copied from Phaser's Camera.preRender so the stub agrees with the engine: + // midX = scrollX + width/2; worldView.x = midX - (width/zoom)/2 + // Anything looser would let a broken zoom anchor pass. + const cam = (x = 0, y = 0, cw = w, ch = h) => ({ + x, y, width: cw, height: ch, + scrollX: 0, scrollY: 0, zoom: 1, ignored: [], + worldView: { x: 0, y: 0, width: cw, height: ch, right: cw, bottom: ch }, + setBounds() { return this; }, setBackgroundColor() { return this; }, + setZoom(z) { this.zoom = z; this._sync(); return this; }, + setScroll(sx, sy) { this.scrollX = sx; this.scrollY = sy; this._sync(); return this; }, + centerOn(px, py) { this.scrollX = px - cw / 2; this.scrollY = py - ch / 2; this._sync(); return this; }, + getWorldPoint(px, py) { + return { x: this.worldView.x + px / this.zoom, y: this.worldView.y + py / this.zoom }; + }, + ignore(o) { this.ignored.push(o); return this; }, + shake() { return this; }, + _sync() { + this.worldView.width = cw / this.zoom; + this.worldView.height = ch / this.zoom; + this.worldView.x = this.scrollX + cw / 2 - this.worldView.width / 2; + this.worldView.y = this.scrollY + ch / 2 - this.worldView.height / 2; + this.worldView.right = this.worldView.x + this.worldView.width; + this.worldView.bottom = this.worldView.y + this.worldView.height; + }, + }); + const main = cam(); + const scene = { + created, + scale: { width: w, height: h }, + time: { now: 1000, delayedCall: () => ({ remove() {} }) }, + tweens: { add: () => ({}) }, + input: { activePointer: { x: 0, y: 0 }, on() {}, off() {}, keyboard: { addKeys: () => ({}), on() {} }, mouse: { disableContextMenu() {} } }, + events: { once() {}, on() {} }, + cameras: { main, cameras: [main], add: () => cam(), remove() {} }, + add: { + container: () => container(), + graphics: () => gfx(), + image: (x, y, key, frame) => base('image', { x, y, texture: key, frame }), + rectangle: (x, y, rw, rh) => base('rectangle', { x, y, width: rw, height: rh }), + text: (x, y, t) => base('text', { x, y, text: t, width: 10, height: 10, setText(v) { this.text = v; return this; }, setColor() { return this; } }), + renderTexture: (x, y, rw, rh) => base('renderTexture', { + x, y, width: rw, height: rh, draws: 0, + beginDraw() { return this; }, batchDraw() { this.draws++; return this; }, endDraw() { return this; }, + draw() { this.draws++; return this; }, + }), + circle: (x, y) => base('circle', { x, y }), + }, + make: { image: (cfg) => base('image', { texture: cfg.key }), graphics: () => gfx() }, + textures: { + exists: (k) => textures.has(k), + remove: (k) => textures.delete(k), + get: (k) => textures.get(k), + createCanvas(key, cw2, ch2) { + if (!Number.isFinite(cw2) || !Number.isFinite(ch2) || cw2 <= 0 || ch2 <= 0) { + throw new Error(`createCanvas(${key}) got ${cw2}x${ch2}`); + } + const frames = []; + const t = { + key, width: cw2, height: ch2, frames, context: ctxStub, + getContext: () => ctxStub, refresh() {}, setFilter() { return this; }, + add(f) { frames.push(f); return this; }, + }; + textures.set(key, t); + return t; + }, + }, + }; + return scene; +} diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js new file mode 100644 index 0000000..4056e89 --- /dev/null +++ b/tools/verifyTotalAnnihilation.js @@ -0,0 +1,856 @@ +#!/usr/bin/env node +// Verifies the Total Annihilation engine, data and AI end to end. +// +// node tools/verifyTotalAnnihilation.js [--quick] +// +// Everything here runs headless: TARules/TALogic/TANav/TAMapGen/TAAI import no Phaser. The +// campaign section is the payoff of routing every order through TALogic.issueOrder — the AI +// plays the HUMAN side, so each mission gets an automated winnability assertion rather than a +// promise that it is probably beatable. + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { compileRules, armorMul } from '../src/games/totalannihilation/TARules.js'; +import { generateMap, decodeMap } from '../src/games/totalannihilation/TAMapGen.js'; +import * as L from '../src/games/totalannihilation/TALogic.js'; +import { runAI } from '../src/games/totalannihilation/TAAI.js'; +import { ensureSheets } from '../src/games/totalannihilation/TAArt.js'; +import TAWorldView, { DEPTHS } from '../src/games/totalannihilation/TAWorldView.js'; +import TAFx from '../src/games/totalannihilation/TAFx.js'; +import { makeStubScene } from './lib/taStubScene.js'; +import { + createNav, findPath, clearanceFor, segmentClear, tileOk, worldToTileX, worldToTileY, +} from '../src/games/totalannihilation/TANav.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const QUICK = process.argv.includes('--quick'); +const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); +const artJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-artwork.json'), 'utf8')); +const campaign = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), 'utf8')); +const opponents = JSON.parse(readFileSync(join(ROOT, 'data/opponents.json'), 'utf8')); +const rules = compileRules(rulesJson); +const HZ = rules.constants.tickHz; + +let pass = 0; const failures = []; +function check(name, cond, detail) { + if (cond) { pass++; return true; } + failures.push(detail ? `${name} — ${detail}` : name); + return false; +} +function section(title) { console.log(`\n── ${title}`); } + +// --------------------------------------------------------------------------- +section('1. Rules integrity'); +// --------------------------------------------------------------------------- +{ + const ids = new Set(); + for (const d of [...rules.units, ...rules.buildings]) { + check(`unique def id ${d.id}`, !ids.has(d.id), 'duplicate'); + ids.add(d.id); + } + for (const d of [...rules.units, ...rules.buildings]) { + check(`${d.id} costs are positive`, (d.cost?.mass ?? 0) >= 0 && (d.cost?.energy ?? 0) >= 0); + check(`${d.id} has hp`, d.hp > 0); + for (const w of d.weaponDefs ?? []) { + for (const a of rules.armorClasses) { + check(`${w.id} vs ${a}`, Number.isFinite(armorMul(w, a)), 'missing armorMul'); + } + } + } + for (const u of rules.units) { + if (!u.buildTime) continue; + const makers = rules.buildings.filter((b) => (b.builds ?? []).includes(u.id)); + check(`${u.id} is buildable by some factory`, makers.length > 0); + } + // Every producible thing must be reachable from the Commander's tech tree, or the player + // can see it in the rules and never build it. + const commander = rules.unitById.commander; + const reach = new Set(commander.builds ?? []); + for (let i = 0; i < 4; i++) { + for (const id of [...reach]) for (const n of rules.defById[id].builds ?? []) reach.add(n); + } + for (const d of [...rules.units, ...rules.buildings]) { + if (d.id === 'commander') continue; + check(`${d.id} reachable from the Commander`, reach.has(d.id)); + } + for (const c of rules.commanders) { + check(`commander ${c.id} maps to a real opponent`, + (opponents.opponents ?? []).some((o) => o.id === c.opponentId), c.opponentId); + check(`commander ${c.id} army exists`, !!rules.armyById[c.armyId]); + } + // The D-Gun must stay manual — auto-firing it turns every Commander into a base turret + // that deletes one attacker per reload, which makes assaulting anything suicide. + check('D-Gun is manual-fire', rules.weaponById.dgun.manual === true); + check('Commander has a non-manual weapon', + (commander.weaponDefs ?? []).some((w) => !w.manual), 'would be defenceless'); +} + +// --------------------------------------------------------------------------- +section('2. Artwork manifest'); +// --------------------------------------------------------------------------- +{ + const sheets = artJson.sheets ?? {}; + check('artwork declares sheets', Object.keys(sheets).length > 0); + for (const [name, s] of Object.entries(sheets)) { + check(`sheet ${name} has a key`, !!s.key); + check(`sheet ${name} has frame size`, s.frameWidth > 0 && s.frameHeight > 0); + check(`sheet ${name} has a kind`, ['unit', 'structure', 'terrain', 'icon'].includes(s.kind), s.kind); + } + for (const a of rules.armies) { + check(`army ${a.id} unit sheet declared`, !!sheets[a.unitSheet], a.unitSheet); + check(`army ${a.id} structure sheet declared`, !!sheets[a.structureSheet], a.structureSheet); + } + for (const [id, t] of Object.entries(artJson.themes ?? {})) { + check(`theme ${id} sheet declared`, !!sheets[t.sheet], t.sheet); + for (const terr of rules.terrain) { + check(`theme ${id} has a colour for ${terr.id}`, !!t.palette?.[terr.id]); + } + } + // terrainFrames drives the procedural terrain sheet's size. It also carries a `_comment` + // key, and one non-numeric value in there turns the frame count into NaN and the painted + // sheet into a zero-height canvas — which fails in the browser as an opaque WebGL error, + // so it gets caught here instead. + const tf = Object.entries(artJson.terrainFrames ?? {}).filter(([k]) => !k.startsWith('_')); + check('terrainFrames declares frames', tf.length > 0); + for (const [name, v] of tf) { + check(`terrainFrames.${name} is a frame index`, Number.isInteger(v) && v >= 0, String(v)); + } + for (const terr of rules.terrain) { + check(`terrain "${terr.id}" has a frame in terrainFrames`, + tf.some(([name]) => name === terr.id) || Number.isInteger(terr.frame)); + } + const maxTerrainFrame = Math.max(...tf.map(([, v]) => v)) + 1; + for (const [name, sheet] of Object.entries(sheets)) { + if (sheet.kind !== 'terrain') continue; + const cols = sheet.cols ?? 8; + check(`${name} layout is fully numeric`, + [sheet.frameWidth, sheet.frameHeight, cols].every((v) => Number.isFinite(v) && v > 0)); + check(`${name} holds every terrain frame`, maxTerrainFrame <= cols * Math.ceil(maxTerrainFrame / cols)); + } + + // Frame indices must exist within each sheet's declared capacity. + for (const d of [...rules.units, ...rules.buildings]) { + for (const a of rules.armies) { + const sheet = sheets[d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet]; + const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8); + check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap); + if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap); + } + } +} + +// --------------------------------------------------------------------------- +section('2b. Procedural art actually paints'); +// --------------------------------------------------------------------------- +{ + // TAArt imports no Phaser, so the painters can be run right here against a stub canvas. + // Checking the artwork JSON alone is not enough: the terrain sheet's frame count is DERIVED + // from that JSON, and a bad derivation produced a zero-height texture that only failed in + // the browser, as a WebGL error pointing nowhere near the cause. Running the painters also + // catches NaN geometry, which paints nothing at all and is otherwise invisible until a + // player sees a blank tank. + const bad = []; + const num = (where, ...vals) => { + for (const v of vals) if (typeof v === 'number' && !Number.isFinite(v)) bad.push(where); + }; + const mkCtx = () => new Proxy({}, { + get: (_t, prop) => { + if (prop === 'save' || prop === 'restore' || prop === 'beginPath' || prop === 'closePath' + || prop === 'fill' || prop === 'stroke') return () => {}; + if (typeof prop === 'string') return (...args) => num(prop, ...args); + return () => {}; + }, + set: (_t, prop, value) => { num(String(prop), value); return true; }, + }); + const made = new Map(); + const scene = { + textures: { + exists: (k) => made.has(k), + remove: (k) => made.delete(k), + createCanvas(key, w, h) { + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + bad.push(`createCanvas(${key}, ${w}, ${h})`); + } + const frames = []; + const tex = { + width: w, height: h, frames, + getContext: () => mkCtx(), + refresh() {}, + add(f, _s, fx, fy, fw, fh) { + num(`add(${key})`, fx, fy, fw, fh); + if (fx + fw > w || fy + fh > h) bad.push(`frame ${f} outside ${key}`); + frames.push(f); + }, + }; + made.set(key, tex); + return tex; + }, + }, + }; + + const { keys, procedural } = ensureSheets(scene, rules, artJson); + for (const name of Object.keys(artJson.sheets ?? {})) { + check(`sheet ${name} resolves to a texture key`, !!keys[name]); + const tex = made.get(keys[name]); + check(`sheet ${name} painted a non-empty canvas`, !!tex && tex.width > 0 && tex.height > 0, + tex ? `${tex.width}x${tex.height}` : 'no texture'); + check(`sheet ${name} registered frames`, !!tex && tex.frames.length > 0); + } + check('every sheet fell back to a painted stand-in', procedural.length === Object.keys(artJson.sheets ?? {}).length, + `${procedural.length} procedural`); + check('no painter emitted a non-finite value', bad.length === 0, [...new Set(bad)].slice(0, 5).join(', ')); + + // Every frame a def references must have been registered by the painter that owns it. + for (const d of [...rules.units, ...rules.buildings]) { + for (const a of rules.armies) { + const sheetName = d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet; + const tex = made.get(keys[sheetName]); + check(`${d.id} frame ${d.frame} was painted on ${sheetName}`, tex?.frames.includes(d.frame)); + if (d.turretFrame != null) { + check(`${d.id} turret frame was painted`, tex?.frames.includes(d.turretFrame)); + } + if (d.buildFrame != null) { + check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame)); + } + } + } + for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) { + if (sheet.kind !== 'terrain') continue; + const tex = made.get(keys[name]); + for (const terr of rules.terrain) { + check(`terrain ${terr.id} frame ${terr.frame} painted on ${name}`, tex?.frames.includes(terr.frame)); + } + } +} + +// --------------------------------------------------------------------------- +section('2c. View layering and first frame'); +// --------------------------------------------------------------------------- +{ + // TAWorldView and TAFx import no Phaser either, so the real render path runs here against + // a stub scene. This section exists because two bugs got past every data-level check and + // straight into the browser: the fog sheet drew UNDER the terrain (a Phaser Container + // renders in insertion order and never sorts by depth on its own), and the camera walked + // off the map before the first frame. Both are invisible to any test that only inspects + // simulation state. + const map = generateMap(rules, { seed: 123456, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 }); + const st = L.createMatch(rules, { seed: 123456, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] }); + const scene = makeStubScene(); + let view = null; + try { + view = new TAWorldView(scene, rules, artJson, st, 0); + const fx = new TAFx(scene, view.worldRoot, DEPTHS); + view.setFogEnabled(true); + const start = st.starts.find((x) => x.army === 0); + view.centerOn(start.x * st.tileSize, start.y * st.tileSize); + const out = view.render(0); + check('render() returns fx payloads', !!out && Array.isArray(out.projectiles) && Array.isArray(out.nanoLinks)); + + const order = view.worldRoot.list; + const lastChunk = Math.max(-1, ...order.map((o, i) => (o.type === 'renderTexture' ? i : -1))); + check('terrain chunks were painted', lastChunk >= 0); + check('fog draws ON TOP of terrain', order.indexOf(view.fogImg) > lastChunk, + `fog at ${order.indexOf(view.fogImg)}, last chunk at ${lastChunk}`); + check('fx layers are in the world container', + order.includes(fx.gUnder) && order.includes(fx.gOver)); + + const own = st.entities.find((e) => e.army === 0); + const foe = st.entities.find((e) => e.army === 1); + const ownSprite = view.sprites.get(own.id); + check('the player\'s starting unit has a sprite', !!ownSprite); + check('own unit draws on top of terrain', order.indexOf(ownSprite.img) > lastChunk); + check('own unit is visible through fog', ownSprite.img.visible); + check('the enemy is hidden at match start', !view.visibleToPlayer(foe)); + + // The Commander must be ON SCREEN when the match opens — an off-screen start reads to a + // player as "I have no units" and there is nothing they can do about it. + const cam = scene.cameras.main.worldView; + const onScreen = own.x >= cam.x && own.x <= cam.x + cam.width + && own.y >= cam.y && own.y <= cam.y + cam.height; + check('the opening camera frames the player\'s Commander', onScreen, + `unit ${own.x.toFixed(0)},${own.y.toFixed(0)} vs view ${cam.x.toFixed(0)},${cam.y.toFixed(0)} ${cam.width}x${cam.height}`); + + // Wheel zoom must keep the world point under the cursor fixed, at any cursor position — + // including well away from the screen centre, which is where a centre-anchored zoom (or a + // correction computed from a stale camera matrix) visibly drifts. + for (const [fx2, fy2] of [[960, 540], [320, 220], [1700, 900], [40, 1040]]) { + for (const dir of [1, 1, -1, -1, -1, 1]) { + const beforePt = view.worldPoint(fx2, fy2); + const beforeZoom = view.zoom; + view.zoomBy(dir, fx2, fy2); + if (view.zoom === beforeZoom) continue; // already at the end of the ladder + const afterPt = view.worldPoint(fx2, fy2); + const drift = Math.hypot(afterPt.x - beforePt.x, afterPt.y - beforePt.y); + check(`zoom at (${fx2},${fy2}) keeps the cursor's world point fixed`, drift < 0.5, + `drifted ${drift.toFixed(1)}px`); + } + } + + // Depth bands must stay in the intended order. + check('bars draw above actors', DEPTHS.bars > DEPTHS.actor); + check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor)); + check('selection draws below actors', DEPTHS.selection < DEPTHS.actor); + } catch (e) { + check('the view layer builds and renders a frame', false, e.message); + } +} + +// --------------------------------------------------------------------------- +section('2d. Container hitbox lint'); +// --------------------------------------------------------------------------- +{ + // TAHud/TAScreens import Phaser directly, so they cannot be constructed here. This is a + // source lint instead, guarding one specific mistake that is invisible until a human tries + // to click something: a Phaser Container's origin is hard-coded to 0.5, so setInteractive() + // centres the hit area on the container's position. A child drawn from a top-left origin + // then sits half a button down-right of the region that actually receives the click. + const files = ['src/games/totalannihilation/TAHud.js', 'src/games/totalannihilation/TAScreens.js']; + for (const rel of files) { + const src = readFileSync(join(ROOT, rel), 'utf8'); + const lines = src.split('\n'); + let offenders = 0; + lines.forEach((line, i) => { + if (!/add\.container\(/.test(line)) return; + // Look at the block this container is built in. + const block = lines.slice(i, i + 26).join('\n'); + if (!/setInteractive\(/.test(block)) return; + if (/setOrigin\(\s*0\s*,\s*0\s*\)/.test(block)) offenders++; + }); + check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`, + offenders === 0, `${offenders} block(s)`); + } +} + +// --------------------------------------------------------------------------- +section('3. Economy fixtures'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 7, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 7, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const a = st.armies[0]; + a.mass = 0; a.energy = 0; + for (let i = 0; i < HZ; i++) L.tick(st, rules); + check('idle army banks its commander income', a.energy > 0 && a.mass > 0); + + // Building an Energy Generator takes exactly buildTime seconds at nominal build power, + // given enough stored resources that nothing stalls. + const st2 = L.createMatch(rules, { seed: 8, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const b = st2.armies[0]; + b.mass = 99999; b.energy = 99999; b.massCap = 99999; b.energyCap = 99999; + const cmd = st2.entities.find((e) => e.army === 0); + const gen = rules.buildingById.energygen; + let tx = worldToTileX(st2.nav, cmd.x) + 2, ty = worldToTileY(st2.nav, cmd.y); + while (!L.canPlaceAt(st2, rules, tx, ty, gen) && tx < st2.w - 4) tx++; + const r = L.issueOrder(st2, rules, { army: 0, unitIds: [cmd.id], order: { type: 'build', defId: 'energygen', tx, ty } }); + check('build order accepted', r.ok, r.error); + let ticks = 0; + while (ticks < 200 * HZ) { + b.mass = 99999; b.energy = 99999; // hold the economy open so only build power matters + L.tick(st2, rules); ticks++; + const site = st2.entities.find((e) => e.defId === 'energygen'); + if (site && !site.site) break; + } + const secs = ticks / HZ; + const expected = gen.buildTime * (rules.constants.buildPowerNominal / rules.unitById.commander.buildPower); + check('unstalled build takes buildTime seconds', Math.abs(secs - expected) <= expected * 0.35 + 2, + `${secs.toFixed(1)}s vs ${expected}s`); + + // Stall factor: halve the available mass flow and construction must slow, not stop. + const st3 = L.createMatch(rules, { seed: 9, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const c = st3.armies[0]; + c.mass = 0; c.energy = 0; + L.tick(st3, rules); + check('stall factors stay in [0,1]', c.stallM >= 0 && c.stallM <= 1 && c.stallE >= 0 && c.stallE <= 1); + check('buildEff is the binding factor', Math.abs(c.buildEff - Math.min(c.stallE, c.stallM)) < 1e-9); + + // Storage is capped and the overflow is discarded rather than silently banked. + const st4 = L.createMatch(rules, { seed: 10, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const d = st4.armies[0]; + d.energy = d.energyCap; d.mass = d.massCap; + for (let i = 0; i < HZ * 5; i++) L.tick(st4, rules); + check('stored energy never exceeds cap', d.energy <= d.energyCap + 1e-6); + check('stored mass never exceeds cap', d.mass <= d.massCap + 1e-6); + + // A Mass Generator on a metal patch must out-yield one on plain ground. + const mgen = rules.buildingById.massgen; + check('mass generator has a terrain multiplier', !!mgen.terrainMultiplier); + const multTerrain = rules.terrain.find((t) => t[mgen.terrainMultiplier] > 1); + check('some terrain carries that multiplier', !!multTerrain); +} + +// --------------------------------------------------------------------------- +section('4. Pathfinding'); +// --------------------------------------------------------------------------- +{ + const ts = rules.constants.tileSize; + const mk = (rows) => { + const w = rows[0].length, h = rows.length; + const terrain = new Uint8Array(w * h); + const wall = rules.terrainByCh['^'].index, open = rules.terrainByCh['.'].index; + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open; + return createNav(rules, { w, h, terrain }); + }; + const nav = mk([ + '..........', + '..######..', + '..#....#..', + '..#....#..', + '..######..', + '..........', + ]); + const mc = 'tread'; + check('tread is a declared movement class', mc in rules.moveClasses); + const at = (nav2, x, y) => y * nav2.w + x; + const p = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 9, 5)); + check('A* finds a route around an obstacle', !!p && p.length > 0); + const inside = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 4, 3)); + check('sealed region is unreachable', !inside); + + // Clearance: a wide unit must refuse a one-tile gap but accept a wide corridor. + const narrow = mk([ + '####.####', + '####.####', + '####.####', + ]); + const wide = mk([ + '##.....##', + '##.....##', + '##.....##', + ]); + const big = clearanceFor(rules.sizeClasses.large.radius, ts); + check('large clearance requirement is > 1 tile', big > 1); + check('large unit refuses a 1-wide corridor', !findPath(narrow, mc, big, 4, at(narrow, 4, 2))); + check('large unit accepts a wide corridor', !!findPath(wide, mc, big, at(wide, 3, 0), at(wide, 5, 2))); + + // String-pulling must never shortcut through a blocked tile. + const blocked = mk(['...', '.#.', '...']); + check('segmentClear rejects a line through a wall', + !segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 2.5 * ts)); + check('segmentClear accepts a clear line', + segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts)); +} + +// --------------------------------------------------------------------------- +section('5. Movement, separation and size classes'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 21, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + // Find a genuinely open tile with open neighbours — the middle of a generated map is as + // likely to be a lake as a field, and units shoved out of water prove nothing. + const openTile = (() => { + for (let y = 3; y < map.h - 3; y++) { + for (let x = 3; x < map.w - 3; x++) { + let ok = true; + for (let dy = -2; dy <= 2 && ok; dy++) { + for (let dx = -2; dx <= 2 && ok; dx++) { + if (!tileOk(st.nav, 'foot', 1, x + dx, y + dy)) ok = false; + } + } + if (ok) return { x, y }; + } + } + return { x: (map.w / 2) | 0, y: (map.h / 2) | 0 }; + })(); + const cx = openTile.x * st.tileSize + st.tileSize / 2; + const cy = openTile.y * st.tileSize + st.tileSize / 2; + check('found open ground for the packing fixture', tileOk(st.nav, 'foot', 1, openTile.x, openTile.y)); + + // The size-class requirement, asserted: three smalls share one tile, two mediums cannot. + const packed = (defId, n) => { + const s2 = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const made = []; + for (let i = 0; i < n; i++) { + const u = L.spawnUnit(s2, rules, 0, defId, cx + (i - n / 2) * 6, cy + (i % 2) * 6); + if (u) made.push(u); + } + for (let i = 0; i < 60; i++) L.tick(s2, rules); + const ts = s2.tileSize; + return made.filter((u) => Math.abs(u.x - cx) <= ts / 2 && Math.abs(u.y - cy) <= ts / 2).length; + }; + // The size-class contract is a statement about collision radii, so assert it there — and + // then confirm the emergent behaviour matches, which is the part a player actually sees. + const ts0 = rules.constants.tileSize; + const small = rules.sizeClasses.small, medium = rules.sizeClasses.medium, large = rules.sizeClasses.large; + check('small units are under half a tile wide', small.radius * 2 <= ts0 / 2, `${small.radius * 2}px`); + check('medium units are about one tile wide', + medium.radius * 2 > ts0 / 2 && medium.radius * 2 <= ts0, `${medium.radius * 2}px`); + check('large units span more than one tile', large.radius * 2 > ts0, `${large.radius * 2}px`); + check('large units claim a multi-tile footprint', (large.footprint ?? 1) > 1); + check('3 small units settle inside one tile', packed('infantry', 3) >= 3, `${packed('infantry', 3)}`); + check('3 medium units cannot settle inside one tile', packed('tank', 3) < 3, `${packed('tank', 3)}`); + + // Settled units must not overlap. + for (let i = 0; i < 12; i++) L.spawnUnit(st, rules, 0, 'tank', cx + (i % 4) * 30, cy + Math.floor(i / 4) * 30); + for (let i = 0; i < 120; i++) L.tick(st, rules); + const mine = st.entities.filter((e) => !e.dead && e.army === 0 && e.defId === 'tank'); + let worst = 0; + for (let i = 0; i < mine.length; i++) { + for (let j = i + 1; j < mine.length; j++) { + const overlap = (mine[i].radius + mine[j].radius) - Math.hypot(mine[i].x - mine[j].x, mine[i].y - mine[j].y); + worst = Math.max(worst, overlap); + } + } + check('settled units barely overlap', worst < 12, `worst overlap ${worst.toFixed(1)}px`); + check('all positions finite', st.entities.every((e) => Number.isFinite(e.x) && Number.isFinite(e.y))); +} + +// --------------------------------------------------------------------------- +section('6. Combat'); +// --------------------------------------------------------------------------- +{ + const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); + raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders + const combatRules = compileRules(raw); + const map = generateMap(combatRules, { seed: 31, size: 'small', symmetry: 'mirror-x' }); + const cx = (map.w / 2) * combatRules.constants.tileSize, cy = (map.h / 2) * combatRules.constants.tileSize; + + const duel = (aId, na, bId, nb, sec = 150) => { + const st = L.createMatch(combatRules, { seed: 31, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (const e of st.entities) e.dead = true; + st.entities = []; + const line = (army, id, n, dx) => { + for (let i = 0; i < n; i++) { + L.spawnUnit(st, combatRules, army, id, + cx + dx - Math.sign(dx) * Math.floor(i / 10) * 40, cy - 180 + (i % 10) * 40); + } + }; + line(0, aId, na, -240); line(1, bId, nb, 240); + st.over = null; + for (const a of st.armies) a.alive = true; + const ids = (army) => st.entities.filter((e) => e.army === army && !e.dead).map((e) => e.id); + L.issueOrder(st, combatRules, { army: 0, unitIds: ids(0), order: { type: 'attackMove', x: cx + 240, y: cy } }); + L.issueOrder(st, combatRules, { army: 1, unitIds: ids(1), order: { type: 'attackMove', x: cx - 240, y: cy } }); + let t = 0; + for (; t < sec * HZ; t++) { + L.tick(st, combatRules); + const a0 = st.entities.some((e) => !e.dead && e.army === 0); + const a1 = st.entities.some((e) => !e.dead && e.army === 1); + if (!a0 || !a1) break; + } + const left = (a) => st.entities.filter((e) => !e.dead && e.army === a).length; + const hp = (a) => st.entities.filter((e) => !e.dead && e.army === a).reduce((s, e) => s + e.hp, 0); + return { a: left(0), b: left(1), hpA: hp(0), hpB: hp(1), secs: t / HZ }; + }; + const cost = (id) => combatRules.unitById[id].cost.mass + combatRules.unitById[id].cost.energy / 4; + const equal = (a, b, n = 8) => { + const nb = Math.max(1, Math.round(n * cost(a) / cost(b))); + const r = duel(a, n, b, nb); + // Score on surviving fraction of the force each side paid for, so a slow grind that the + // tanks are clearly winning counts as a win even if the clock runs out first. + r.fracA = r.a / n; r.fracB = r.b / nb; + return r; + }; + + // Ballistic shells travel 35px per 20Hz tick, so a point-sampled hit test tunnels straight + // through its target. Tanks doing real damage is the observable proof the sweep works. + const tvi = equal('tank', 'infantry'); + check('tanks beat equal-cost infantry', tvi.fracA > tvi.fracB, + `${tvi.a} tanks (${(tvi.fracA * 100) | 0}%) v ${tvi.b} infantry (${(tvi.fracB * 100) | 0}%)`); + const rvs = equal('rockettank', 'sniper'); + check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB, + `${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`); + // Rockets out-range and out-damage armour; that is what makes the roster a triangle + // rather than a ladder where one unit is simply the answer. + const rvt = equal('rockettank', 'tank'); + check('rocket tanks beat equal-cost tanks', rvt.fracA > rvt.fracB, + `${rvt.a} (${(rvt.fracA * 100) | 0}%) v ${rvt.b} (${(rvt.fracB * 100) | 0}%)`); + + // Engagements must take long enough for reinforcement and composition to matter. When + // fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip. + check('an even engagement is not instant', tvi.secs > 5, `${tvi.secs.toFixed(1)}s`); + + // Damage fixtures. + const st = L.createMatch(combatRules, { seed: 32, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const victim = L.spawnUnit(st, combatRules, 1, 'tank', cx, cy); + const before = victim.hp; + L.applyDamage(st, combatRules, victim, 100, { army: 0, id: 0 }); + check('applyDamage subtracts exactly', Math.abs((before - victim.hp) - 100) < 1e-9); + const rifle = combatRules.weaponById.rifle; + check('rifle is strong vs infantry, weak vs medium', + armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3); + const rocket = combatRules.weaponById.rocketpod; + check('rockets favour armour over infantry', armorMul(rocket, 'heavy') > armorMul(rocket, 'infantry') * 3); + + // Commander death explosion. + const dth = combatRules.unitById.commander.deathExplosion; + check('commander has a death explosion', dth && dth.radius > 200 && dth.damage > 500); +} + +// --------------------------------------------------------------------------- +section('7. Fog of war'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 41, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 41, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const mine = st.entities.find((e) => e.army === 0); + const theirs = st.entities.find((e) => e.army === 1); + L.computeVision(st, rules); + check('own units are always visible', L.isVisibleTo(st, 0, mine)); + check('a distant enemy starts hidden', !L.isVisibleTo(st, 0, theirs)); + + // Explored is sticky; visible is not. + const cell = st.tileSize * 2; + const vx = Math.floor(mine.x / cell), vy = Math.floor(mine.y / cell); + const idx = vy * st.visW + vx; + check('own tile is explored', st.armies[0].explored[idx] === 1); + L.spawnUnit(st, rules, 0, 'jeep', theirs.x, theirs.y); + L.computeVision(st, rules); + check('scouting reveals the enemy', L.isVisibleTo(st, 0, theirs)); + + // The AI must be fog-limited too — it may not target something it cannot see. + const st2 = L.createMatch(rules, { seed: 42, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < HZ * 3; i++) { runAI(rules, st2, 0, { skill: 5 }); L.tick(st2, rules); } + const mem = st2.aiMem?.[0]; + const cheated = (mem?.knownEnemies ?? []).filter((k) => { + const e = L.entityById(st2, k.id); + return e && !L.isVisibleTo(st2, 0, e) && k.tick === st2.tick; + }); + check('AI never records an unseen enemy as seen-now', cheated.length === 0, `${cheated.length} leaks`); +} + +// --------------------------------------------------------------------------- +section('8. Map generation'); +// --------------------------------------------------------------------------- +{ + const seeds = QUICK ? 24 : 120; + let symMismatch = 0, badStarts = 0, poorMetal = 0, unreachable = 0; + const sizes = Object.keys(rules.skirmish.sizes); + for (let i = 0; i < seeds; i++) { + const size = sizes[i % sizes.length]; + const sym = rules.skirmish.symmetries[i % rules.skirmish.symmetries.length]; + const map = generateMap(rules, { seed: 5000 + i * 13, size, symmetry: sym, armies: 2 }); + if (map.starts.length !== 2) { badStarts++; continue; } + + if (sym === 'mirror-x') { + for (let y = 0; y < map.h; y++) { + for (let x = 0; x < map.w; x++) { + if (map.terrain[y * map.w + x] !== map.terrain[y * map.w + (map.w - 1 - x)]) { symMismatch++; y = map.h; break; } + } + } + } + // Every start needs metal within reach or its economy can never leave the ground. + const mgen = rules.buildingById.massgen; + for (const s of map.starts) { + let spots = 0; + const R = rules.skirmish.gen.metalSpotRadiusTiles; + for (let y = Math.max(0, s.y - R); y < Math.min(map.h, s.y + R); y++) { + for (let x = Math.max(0, s.x - R); x < Math.min(map.w, s.x + R); x++) { + if (rules.terrain[map.terrain[y * map.w + x]][mgen.terrainMultiplier] > 1) spots++; + } + } + if (spots < 4) poorMetal++; + } + // And the two starts must be mutually reachable by a medium ground unit. + const nav = createNav(rules, map); + const clr = clearanceFor(rules.sizeClasses.medium.radius, rules.constants.tileSize); + const path = findPath(nav, 'tread', clr, + map.starts[0].y * map.w + map.starts[0].x, map.starts[1].y * map.w + map.starts[1].x); + if (!path) unreachable++; + } + check('every generated map places both starts', badStarts === 0, `${badStarts} bad`); + check('mirror-x maps are exactly symmetric', symMismatch === 0, `${symMismatch} mismatches`); + check('every start has metal nearby', poorMetal === 0, `${poorMetal} starved starts`); + check('starts are mutually reachable', unreachable === 0, `${unreachable} unreachable`); +} + +// --------------------------------------------------------------------------- +section('9. Campaign data'); +// --------------------------------------------------------------------------- +{ + const missions = campaign.missions ?? []; + check('campaign has missions', missions.length > 0); + const OBJECTIVES = new Set(['destroyAll', 'destroyCommander', 'survive', 'holdArea', 'reachArea', 'protect', 'buildCount']); + missions.forEach((m, i) => { + let map = null; + try { map = decodeMap(rules, m.map); } catch (e) { /* reported below */ } + check(`${m.id} terrain decodes`, !!map); + if (!map) return; + check(`${m.id} declares both starts`, (map.starts ?? []).length >= 2); + check(`${m.id} theme exists`, !!artJson.themes?.[m.theme], m.theme); + check(`${m.id} player army exists`, !!rules.armyById[m.playerArmy]); + check(`${m.id} player commander exists`, !!rules.commanderById[m.playerCommander]); + check(`${m.id} objective is a known type`, OBJECTIVES.has(m.objective?.type), m.objective?.type); + for (const e of m.enemies ?? []) { + check(`${m.id} enemy commander exists`, !!rules.commanderById[e.commander], e.commander); + const sk = e.aiProfile?.skill; + check(`${m.id} enemy skill is 1-5`, sk >= 1 && sk <= 5, String(sk)); + } + for (const b of map.buildings ?? []) { + check(`${m.id} prebuilt ${b.type} exists`, !!rules.buildingById[b.type], b.type); + check(`${m.id} prebuilt ${b.type} is on the map`, b.tx >= 0 && b.ty >= 0 && b.tx < map.w && b.ty < map.h); + } + for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) { + check(`${m.id} speaker ${line.speaker} exists`, + (opponents.opponents ?? []).some((o) => o.id === line.speaker), line.speaker); + } + // Missions must get harder, or the unlock order is meaningless. + if (i > 0) { + const prev = missions[i - 1].enemies?.[0]?.aiProfile?.skill ?? 0; + check(`${m.id} is no easier than the one before`, (m.enemies?.[0]?.aiProfile?.skill ?? 0) >= prev); + } + }); +} + +// --------------------------------------------------------------------------- +section('10. Serialization and determinism'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 51, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 51, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < HZ * 20; i++) { runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); L.tick(st, rules); } + const blob = L.serialize(st); + const back = L.deserialize(rules, blob); + check('serialize round-trips to the same hash', L.hashState(back) === L.hashState(st)); + check('serialized save is a sane size', blob.length < 400000, `${blob.length} bytes`); + + const run = () => { + const m2 = generateMap(rules, { seed: 52, size: 'small', symmetry: 'mirror-x' }); + const s2 = L.createMatch(rules, { seed: 52, map: m2, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < HZ * 30; i++) { runAI(rules, s2, 0, { skill: 4 }); runAI(rules, s2, 1, { skill: 2 }); L.tick(s2, rules); } + return L.hashState(s2); + }; + check('same seed replays identically', run() === run()); + + // The step() harness must produce the same result as driving tick() directly. + const m3 = generateMap(rules, { seed: 53, size: 'small', symmetry: 'mirror-x' }); + const byTick = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < 100; i++) L.tick(byTick, rules); + const byStep = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < 100; i++) L.step(byStep, rules, rules.stepMs, 4); + check('step() and tick() agree', L.hashState(byStep) === L.hashState(byTick)); +} + +// --------------------------------------------------------------------------- +section('11. Skirmish soak'); +// --------------------------------------------------------------------------- +{ + const games = QUICK ? 8 : 24; + let decided = 0, negative = 0, nonFinite = 0, overCap = 0, peakUnits = 0; + let totalTickMs = 0, ticks = 0, worstTick = 0; + for (let g = 0; g < games; g++) { + const map = generateMap(rules, { seed: 6000 + g * 17, size: g % 2 ? 'medium' : 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 6000 + g, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const cap = rules.constants.unitCapPerArmy; + while (!st.over && st.tick < 1200 * HZ) { + runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); + const t0 = process.hrtime.bigint(); + L.tick(st, rules); + const ms = Number(process.hrtime.bigint() - t0) / 1e6; + totalTickMs += ms; ticks++; worstTick = Math.max(worstTick, ms); + for (const a of st.armies) if (a.mass < -1e-6 || a.energy < -1e-6) negative++; + if (st.tick % 200 === 0) { + for (const e of st.entities) if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) nonFinite++; + for (let i = 0; i < st.armies.length; i++) { + const n = st.entities.filter((e) => !e.dead && e.army === i && !e.isBuilding).length; + peakUnits = Math.max(peakUnits, n); + if (n > cap) overCap++; + } + } + } + if (st.over && st.over.winner >= 0) decided++; + } + const avg = totalTickMs / Math.max(1, ticks); + console.log(` ${decided}/${games} decided · peak ${peakUnits} units/army · tick avg ${avg.toFixed(2)}ms worst ${worstTick.toFixed(1)}ms`); + check('most games reach a decision', decided >= Math.ceil(games * 0.85), `${decided}/${games}`); + check('resources never go negative', negative === 0, `${negative}`); + check('positions stay finite', nonFinite === 0, `${nonFinite}`); + check('unit cap is respected', overCap === 0, `${overCap} breaches`); + // Average is the real budget signal at 20Hz (50ms per tick). The worst case is allowed + // more headroom because one slow tick — a burst of path requests, or a GC landing on it — + // costs a single dropped frame, not a stall; it is printed above either way. + check('average tick is well inside the 20Hz budget', avg < 8, `avg ${avg.toFixed(2)}ms`); + check('no tick blows the frame budget outright', worstTick < 120, `worst ${worstTick.toFixed(1)}ms`); +} + +// --------------------------------------------------------------------------- +section('12. AI skill ladder'); +// --------------------------------------------------------------------------- +{ + const pairs = QUICK ? 6 : 16; + // Each seed is played BOTH ways round so that side advantage — turn order, spawn corner, + // commander profile — cancels exactly instead of being assumed away. Measured over 32 + // mirrored pairs the ladder runs 5v1 ≈ 84%, 3v1 ≈ 81%, 5v3 ≈ 59%; the bars below sit far + // enough below those to be stable at this sample size. + const ladder = (sa, sb) => { + let win = 0, dec = 0; + for (let i = 0; i < pairs; i++) { + for (const flip of [false, true]) { + const map = generateMap(rules, { seed: 7000 + i * 7, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 7000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const p0 = flip ? sb : sa, p1 = flip ? sa : sb; + while (!st.over && st.tick < 1200 * HZ) { + runAI(rules, st, 0, { skill: p0 }); runAI(rules, st, 1, { skill: p1 }); + L.tick(st, rules); + } + if (st.over && st.over.winner >= 0) { + dec++; + if (st.over.winner === (flip ? 1 : 0)) win++; + } + } + } + return dec ? win / dec : 0; + }; + const hi = ladder(5, 1); + console.log(` skill 5 vs skill 1: ${(hi * 100).toFixed(1)}%`); + check('skill 5 beats skill 1 decisively', hi >= 0.72, `${(hi * 100).toFixed(1)}%`); + if (!QUICK) { + const mid = ladder(4, 2); + console.log(` skill 4 vs skill 2: ${(mid * 100).toFixed(1)}%`); + check('skill 4 beats skill 2', mid >= 0.52, `${(mid * 100).toFixed(1)}%`); + } +} + +// --------------------------------------------------------------------------- +section('13. Campaign winnability'); +// --------------------------------------------------------------------------- +{ + // A skill-5 bot plays the PLAYER's side through the same issueOrder API a human uses. This + // is the entire reason the order API is shared, and it is the only way to know a mission is + // actually beatable rather than merely plausible. + const runs = QUICK ? 2 : 4; + for (const m of campaign.missions ?? []) { + let wins = 0, played = 0; + for (let r = 0; r < runs; r++) { + const map = decodeMap(rules, m.map); + const st = L.createMatch(rules, { + seed: (m.seed ?? 1) + r * 101, map, + armies: [ + { armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true }, + ...(m.enemies ?? []).map((e) => ({ + armyId: e.army, commanderId: e.commander, + aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null, + })), + ], + }); + (m.startResources ?? []).forEach((res, i) => { + if (!st.armies[i]) return; + st.armies[i].mass = res.mass ?? st.armies[i].mass; + st.armies[i].energy = res.energy ?? st.armies[i].energy; + }); + while (!st.over && st.tick < 1500 * HZ) { + runAI(rules, st, 0, { skill: 5 }); + for (let i = 1; i < st.armies.length; i++) { + const e = m.enemies?.[i - 1]; + runAI(rules, st, i, { skill: e?.aiProfile?.skill ?? 3, ...(e?.aiProfile ?? {}) }); + } + L.tick(st, rules); + } + played++; + if (st.over && st.over.winner === 0) wins++; + } + const rate = played ? wins / played : 0; + console.log(` ${m.id} ${m.name.padEnd(16)} bot wins ${wins}/${played}`); + check(`${m.id} is winnable by a skill-5 bot`, rate >= 0.5, `${wins}/${played}`); + } +} + +// --------------------------------------------------------------------------- +console.log(`\n${'─'.repeat(60)}`); +if (failures.length) { + console.log(`FAILED — ${pass} checks passed, ${failures.length} failed:`); + for (const f of failures) console.log(` ✗ ${f}`); + process.exit(1); +} +console.log(`OK — all ${pass} checks passed${QUICK ? ' (quick)' : ''}.`);