fertig-classic-games/tools/genTACampaign.js

155 lines
8.2 KiB
JavaScript

#!/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. Giving armed aircraft a
// strafing flight model did the same to m05 later on. Both times the mission was fine and the
// battlefield was not.
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.',
},
{
// Re-seeded twice as combat balance moved under it, both times measured over an 8-run
// sweep: 55930 -> 40411 when armed aircraft stopped being able to hover, then 40411 (which
// fell to 4/8) -> 71644 when the AI learned to build static defences. Cy-Bro is skill 4
// here, which is exactly the level at which defences switch on.
id: 'm05', name: 'Tropic Vice', seed: 71644, 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}`);