#!/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. Aim for >=75% over 8 runs when picking one; // verify §13 only gates on the mission being winnable at all, because a tighter bar off its // small sample would flake on missions that are perfectly fine. // // Re-check these whenever combat balance moves. Making units hold at weapon range instead of // closing to point blank shifted attack/defence enough to turn m02's old seed from playable // into unwinnable (0/8), with no change to the mission's own numbers. 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: 26958, 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, // Matches the engine's default victory rule (constants.victoryDefault). A mission that // wants the long game instead sets `victory: 'annihilation'` alongside this. objective: { type: 'destroyCommander' }, 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}`);