163 lines
9.0 KiB
JavaScript
163 lines
9.0 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 three times as combat balance moved under it, each time 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, then 71644
|
|
// (which fell to 6/8, and lost both of verify §13's own fixed sample runs — seed and
|
|
// seed+101 — even though the full 8-run rate was technically still at the bar) -> 67890
|
|
// (8/8) when rocketpod's armorMul was rebalanced weak-vs-vehicle/unchanged-vs-structure.
|
|
// Cy-Bro is skill 4 here, which is exactly the level at which defences switch on.
|
|
id: 'm05', name: 'Tropic Vice', seed: 67890, 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. Rockets crack their base open — armour holds the line getting there.',
|
|
victory: 'Cy-Bro is scrap on the sand.',
|
|
defeat: 'The push came faster than the plant did.',
|
|
},
|
|
{
|
|
// Re-seeded once, 8-run sweep: 63541 (fell to 0/8) -> 80808 (8/8) when static defence
|
|
// became a skill-3+ behaviour with a PROACTIVE first tower (TAAI.urgentDefence no longer
|
|
// waits for a raid to build the first one). KLAXON here is skill 5, well past that gate,
|
|
// so it now stands up a tower before the player-bot's rush can land — 90123 and 56789 also
|
|
// scored 8/8 in the same sweep if this needs revisiting again.
|
|
id: 'm06', name: 'Annihilation', seed: 80808, 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, dug in behind its own defences. 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}`);
|