diff --git a/data/totalannihilation-campaign.json b/data/totalannihilation-campaign.json index ab3067a..af0804c 100644 --- a/data/totalannihilation-campaign.json +++ b/data/totalannihilation-campaign.json @@ -598,7 +598,7 @@ "id": "m05", "name": "Tropic Vice", "theme": "tropics", - "seed": 55930, + "seed": 71644, "playerArmy": "arm", "playerCommander": "vance", "enemies": [ @@ -646,102 +646,102 @@ "h": 96, "theme": "tropics", "rows": [ - "~~~..........r.....~~~~~~~~~~~~~~~.rr.rr................rr.rr.~~~~~~~~~~~~~~~.....r..........~~~", - "~~.................~~~~~~~~~~~~~~~.rrrrr................rrrrr.~~~~~~~~~~~~~~~.................~~", - "~~..................~~~~..r~~~~~~~~..........................~~~~~~~~r..~~~~..................~~", - "~.....................r........~~~~..........................~~~~........r.....................~", - "...............................~~~~~........................~~~~~...............................", - ".......................r.......~~~~~........................~~~~~.......r.......................", - "......................rr.......~~~~~........................~~~~~.......rr......................", - "......................rr.......~~~~~........................~~~~~.......rr......................", - "....................rrrr.......~~~~~........................~~~~~.......rrrr....................", - "...............rrr^^^^r........~~~~~........................~~~~~........r^^^^rrr...............", - "...............r^^^^^^^^.......~~~~~~r....................r~~~~~~.......^^^^^^^^r...............", - "..............rr^^^^^^^^r......~~~~~~~~~~~~~~~~..~~~~~~~~~~~~~~~~......r^^^^^^^^rr..............", - ".............rrr^^^^^^^^.......~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.......^^^^^^^^rrr.............", - ".............rrr^^^^^^^........~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~........^^^^^^^rrr.............", - "................^^^^............~~~~~~~~~~~~~~~rr~~~~~~~~~~~~~~~............^^^^................", - ".................r..............rr..~~~r...r........r...r~~~..rr..............r.................", - "...............................................rr...............................................", - "r..r...........................................rr...........................................r..r", - "..rrr..rr.^..........................................................................^.rr..rrr..", - "~~.r...rrrr^.................r....................................r.................^rrrr...r.~~", - "~~~.rrr..rr^^................r....................................r................^^rr..rrr.~~~", - "~~~..rr..r^^^^....................................................................^^^^r..rr..~~~", - "~~~......^^^^^....................................................................^^^^^......~~~", - "~~~.....^^^^^^^rr..............................................................rr^^^^^^^.....~~~", - "........^^^^^^^^^..............................................................^^^^^^^^^........", - "........^^^^^^^^^..............................................................^^^^^^^^^........", - ".......r^^^^r.^^^..............................................................^^^.r^^^^r.......", - ".......r^^^rr.r^^^............................................................^^^r.rr^^^r.......", - ".......r^^rrrr..^^^^^^..r..............rr..............rr..............r..^^^^^^..rrrr^^r.......", - "........^...r...^^^^^^^^rr.............rrr.....rr.....rrr.............rr^^^^^^^^...r...^........", - "^^..............^^^^^^^^^r......~~~~...rrrrr^^.rr.^^rrrrr...~~~~......r^^^^^^^^^..............^^", - "^^^.............^^^^^^^^^......~~~~~...rrr^^^^^^^^^^^^rrr...~~~~~......^^^^^^^^^.............^^^", - "^^^^^^....rrr....^^^^^^^^^....~~~~~~.rr..^^^^^^^^^^^^^^..rr.~~~~~~....^^^^^^^^^....rrr....^^^^^^", - "^^^^^^r..rrrr....^^^^^^^^^...r~~~~~~rr...^^^^^^^^^^^^^^...rr~~~~~~r...^^^^^^^^^....rrrr..r^^^^^^", - "^^^^^^...rrrr.....^^^^^^^.....~~~~~~r....^^^^^^^^^^^^^^....r~~~~~~.....^^^^^^^.....rrrr...^^^^^^", - "^^^^r..rr..r.....rr^^^^^.......~~~~~.....^^^^^^^^^^^^^^.....~~~~~.......^^^^^rr.....r..rr..r^^^^", - "^^^r..rrr.......rrr^^^^^........~~~.......^^^^^^^^^^^^.......~~~........^^^^^rrr.......rrr..r^^^", - "^^..rrrr.........r.^^^^r.........~........^^^^^^^^^^^^........~.........r^^^^.r.........rrrr..^^", - "^r.rrrrr........rr.^^^^..........rrr.......^^^^^^^^^^.......rrr..........^^^^.rr........rrrrr.r^", - "..rrrrrr.......rrr..^^^..........rr........r........r........rr..........^^^..rrr.......rrrrrr..", - "....rrrr......rrrr..^^...........rr........rr......rr........rr...........^^..rrrr......rrrr....", - "...............rrr...............rr........rr......rr........rr...............rrr...............", - "................................................................................................", - "................................................................................................", - "................................................................................................", - "................................................................................................", - ".............................rrr................................rrr.............................", - "...................MM........rrr................................rrr........MM...................", - "...................MM.........rrr..............................rrr.........MM...................", - "................................r..............................r................................", - "................................................................................................", - "................................................................................................", - ".....MM.................~~................~~~~~~~~~~~~................~~.................MM.....", - ".....MM................~~~~.............~~~~~~~~~~~~~~~~.............~~~~................MM.....", - ".......................~~~~.............~~~~~~~~~~~~~~~~.............~~~~.......................", - "......................r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......................", - "........MM....MM......r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......MM....MM........", - "........MM....MM......r~~~~............r~~~~~~~~~~~~~~~~r............~~~~r......MM....MM........", - "r........rrr............~~~...............~~~~~~~~~~~~...............~~~............rrr........r", - ".........rrr............~~............................................~~............rrr.........", - "..........r..........................................................................r..........", - ".........~~~~~....................................................................~~~~~.........", - "........~~~~~~....................................................................~~~~~~........", - "........~~~~~~....r..........................................................r....~~~~~~........", - "^....rr.~~~~~~~...rr^^^..................................................^^^rr...~~~~~~~.rr....^", - "^^...rr.~~~~~~~...rr^^^...................^^^^^^^^^^^^...................^^^rr...~~~~~~~.rr...^^", - "^^......~~~~~~~...rr^^^^.................^^^^^^^^^^^^^^.................^^^^rr...~~~~~~~......^^", - "^^......~~~~~~~r....^^^^.................^^^^^^^^^^^^^^.................^^^^....r~~~~~~~......^^", - "^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^", - "^^r.....~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~.....r^^", - "^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^", - "^^.....~~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~~.....^^", - "^^....r~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~r....^^", - "^.r..rr~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~rr..r.^", - "^.rrr..~~~~~~~~.....^^^.............rr....^^^^^^^^^^^^....rr.............^^^.....~~~~~~~~..rrr.^", - "..rrr..~~~~~~~~....r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r....~~~~~~~~..rrr..", - "..rrr..~~~~~~~~.....^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^.....~~~~~~~~..rrr..", - "..rrr..~~~~~~~~~...r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r...~~~~~~~~~..rrr..", - "..rrr..~~~~~~~~~....^^^..............rrrrr^^^^^^^^^^^^rrrrr..............^^^....~~~~~~~~~..rrr..", - "..rrr..~~~~~~~~.....^^^...............rrrr^^^^^^^^^^^^rrrr...............^^^.....~~~~~~~~..rrr..", - "..rrr..~~~~~~~~.....^^^...............rrr.^^^^^^^^^^^^.rrr...............^^^.....~~~~~~~~..rrr..", - "^.rrr...~~~~~~~.....^^^................r..^^^^^^^^^^^^..r................^^^.....~~~~~~~...rrr.^", - "^^rrr....~~~~~......^^....................^^^^^^^^^^^^....................^^......~~~~~....rrr^^", - "^^r.......................................^^^^^^^^^^^^.......................................r^^", - "^^^.......................................^^^^^^^^^^^^.......................................^^^", - "^^^.........................................^......^.........................................^^^", - "^^^^.........................^^^^^^^^^^^................^^^^^^^^^^^.........................^^^^", - "^^^^........................^^^^^^^^^^^^^..............^^^^^^^^^^^^^........................^^^^", - "^^^^^.......................^^^^^^^^^^^^^..............^^^^^^^^^^^^^.......................^^^^^", - "^^^^^.......................^^^^^^^^^^^^^.....r..r.....^^^^^^^^^^^^^.......................^^^^^", - "^^^^^.......................^^^^^^^^^^^^^....rrrrrr....^^^^^^^^^^^^^.......................^^^^^", - "^^^^.........................^^^^^^^^^^......rrrrrr......^^^^^^^^^^.........................^^^^", - "^^r...............................r..........rrrrrr..........r...............................r^^", - ".r.....................r................................................r.....................r.", - ".............rr.......rr~~............................................~~rr.......rr.............", - "r........rrrrrrr.......~~~~.......r..........................r.......~~~~.......rrrrrrr........r" + "~~r....^^^^^^^^^^^^...........rr~~~...r...^^^^^^^^^^^^...r...~~~rr...........^^^^^^^^^^^^....r~~", + "~~.....^^^^^^^^^^^^...........r.~~~........^^^....^^^........~~~.r...........^^^^^^^^^^^^.....~~", + "~~....rr^^^^^^^^^^^............r~~~~.r....................r.~~~~r............^^^^^^^^^^^rr....~~", + "~~~......^^^...................~~~~~~~rr................rr~~~~~~~...................^^^......~~~", + "~~~~.....rr....................~~~~~~~~~................~~~~~~~~~....................rr.....~~~~", + "~~~~......rr.....................~~~~~~rr..............rr~~~~~~.....................rr......~~~~", + "~~~~......rr..............rr.....~~.....rr............rr.....~~.....rr..............rr......~~~~", + "~~~..................rrrrrrr.....~......rr............rr......~.....rrrrrrr..................~~~", + "~~~.................rrrrrrrr.....~r..........................r~.....rrrrrrrr.................~~~", + "~~~~................rrrr.............rr..................rr.............rrrr................~~~~", + "~~~~~...............rrrr.............rr..................rr.............rrrr...............~~~~~", + "~~~~~~................rrr...........rr....................rr...........rrr................~~~~~~", + "~~~~~~r...............rr................................................rr...............r~~~~~~", + "~~~~~.rr................................................................................rr.~~~~~", + "~~~...rr................................................................................rr...~~~", + "~~rr........................................................................................rr~~", + "~~rr....................rr..............rr............rr..............rr....................rr~~", + ".................rr....rrr..............rr............rr..............rrr....rr.................", + ".................rr..~~.....r....r...........rrrrrr...........r....r.....~~..rr.................", + ".................r..~~~~...~~~~~~~~~.......^^^^..^^^^.......~~~~~~~~~...~~~~..r.................", + "..................r.~~~~..~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~..~~~~.r..................", + "..................r~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~r..................", + ".................r.~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~.r.................", + "................rrr~~~~~~~~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~~~~~~~~rrr................", + "..............rrrr~~~~~~~.~~~~~~~~~~~...rr.^^^^..^^^^.rr...~~~~~~~~~~~.~~~~~~~rrrr..............", + "..............rrr.~~~~~~...~~~~~~~~~~...rrr^^^^..^^^^rrr...~~~~~~~~~~...~~~~~~.rrr..............", + "..............r............................^^^^..^^^^............................r..............", + "..............r...........................^^^^^^^^^^^^...........................r..............", + "............rrrrrr.......................^^^^^^^^^^^^^^.......................rrrrrr............", + "........rr.rrrrrrr......................^^^^^^^^^^^^^^^^......................rrrrrrr.rr........", + "........rrrrr..rrr....................^^^^^^^^^^^^^^^^^^^^....................rrr..rrrrr........", + "...........rr...rr...................^^^^^^^^^^^^^^^^^^^^^^...................rr...rr...........", + "...........rr....r...........^......^^^^^^^^^^^^^^^^^^^^^^^^......^...........r....rr...........", + "...........................r^^^...r^^^^^^^^^^^^^^^^^^^^^^^^^^r...^^^r...........................", + "r...........................^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^....^^^...........................r", + "...................................r^^^^^^^^^^^^^^^^^^^^^^^^r...................................", + "......................................^^^^^^^^^^^^^^^^^^^^......................................", + ".................................rrr.....^^^^^^..^^^^^^.....rrr.................................", + ".........MM.....................rrrrr.....^^^^....^^^^.....rrrrr.....................MM.........", + ".........MM.....^^...............rrrrr....................rrrrr...............^^.....MM.........", + "................^^...............rrrrr....................rrrrr...............^^................", + "...............^^^...............rrrrr....................rrrrr...............^^^...............", + "................^^^...............r..........................r...............^^^................", + "~~...............^^..........................................................^^...............~~", + "~~~.MM...........^^..........................................................^^...........MM.~~~", + "~~~.MM.......MM...^...rr................................................rr...^...MM.......MM.~~~", + "~~...........MM.......rr................................................rr.......MM...........~~", + "~.....................rr................................................rr.....................~", + "r..............................................................................................r", + "....MM............rr........................................................rr............MM....", + "....MM............rr........................................................rr............MM....", + "..................rr~~~~................................................~~~~rr..................", + "..................r.~~~~~~~..r..............^^....^^..............r..~~~~~~~.r..................", + "...................~~~~~~~~rr..............^^^....^^^..............rr~~~~~~~~...................", + "...................~~~~~~~~~...............^^^^..^^^^...............~~~~~~~~~...................", + "rr.................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~.................rr", + "rrr................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~................rrr", + "..r................~~~~~~~~~................^^....^^................~~~~~~~~~................r..", + "...................~~~~~~~~..........................................~~~~~~~~...................", + "..................r~~~~~~~~.r......................................r.~~~~~~~~r..................", + "...................r~~~~~~.rrr.rr......rrrr..........rrrr......rr.rrr.~~~~~~r...................", + "...................r~~~~rrrrrrrrr.....rrrr............rrrr.....rrrrrrrrr~~~~r...................", + "....................rr..rrrrrr.rrr....rrrr............rrrr....rrr.rrrrrr..rr....................", + ".............r..........rrrrr....r....rr................rr....r....rrrrr..........r.............", + "...........rrrrr.........rr..........rr..................rr..........rr.........rrrrr...........", + "..........~~~.rr.........r...........rr.....~~....~~.....rr...........r.........rr.~~~..........", + "........r~~~~~rr.........rr..........rr....~~~~~~~~~~....rr..........rr.........rr~~~~~r........", + "........r~~~~~rrr.......rrr..........rrr...~~~~~~~~~~...rrr..........rrr.......rrr~~~~~r........", + ".........~~~~~..rr......rrr...........rr...r~~~rr~~~r...rr...........rrr......rr..~~~~~.........", + ".........~~~~~..rr......rrr..........................................rrr......rr..~~~~~.........", + "........~~~~~~..rr............................................................rr..~~~~~~........", + "........~~~~~~..rr............................................................rr..~~~~~~........", + "........~~~~~~..rr............................................................rr..~~~~~~........", + "........~~~~~~~.......................rrrr............rrrr.......................~~~~~~~........", + ".......~~~~~~~~......r..........r^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^r..........r......~~~~~~~~.......", + ".......~~~~~~~~.....rrrr........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^........rrrr.....~~~~~~~~.......", + ".......~~~~~~~~....rr.rr.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......rr.rr....~~~~~~~~.......", + ".......~~~~~~~~.....~~.r.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......r.~~.....~~~~~~~~.......", + ".......~~~~~~~~.....~~..r......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......r..~~.....~~~~~~~~.......", + ".......~~~~~~~~.....~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~.....~~~~~~~~.......", + "......r~~~~~~~~.....~~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~~.....~~~~~~~~r......", + "......rr~~~~~~~.....~~~..rrr.....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....rrr..~~~.....~~~~~~~rr......", + "......rr~~~~~~~.....~~~~............^^^^^^^^^^^^^^^^^^^^^^^^............~~~~.....~~~~~~~rr......", + "......rr.~~~~~r.....~~~~...............rrrr..........rrrr...............~~~~.....r~~~~~.rr......", + "^.........~~~~r...r~~~~~~..............rrrrrrrr..rrrrrrrr..............~~~~~~r...r~~~~.........^", + "^^............rr..r~~~~~~~................rrrrrrrrrrrr................~~~~~~~r..rr............^^", + "^^^...........rr..r~~~~~~~................rrr..rr..rrr................~~~~~~~r..rr...........^^^", + "^^^.....rr........r~~~~~~~...............rrrr......rrrr...............~~~~~~~r........rr.....^^^", + "^^^^....rr.........~~~~~~~~...............rr........rr...............~~~~~~~~.........rr....^^^^", + "^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^", + "^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^", + "^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^", + "^^^................~~~~~~~~..........................................~~~~~~~~................^^^", + "^^^................~~~~~~~~...........r....r........r....r...........~~~~~~~~................^^^", + "^^^.................~~~~~~...........rrr..rrr......rrr..rrr...........~~~~~~.................^^^", + "^^.....r......rr.r..~~~~~~............r....r........r....r............~~~~~~..r.rr......r.....^^" ], "starts": [ { diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index 8be5afa..d1f8688 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -14,9 +14,11 @@ "an optional second sprite that aims independently of the hull.", "", "Domains: a unit's `domain` is \"ground\" (the default) or \"air\". Air units ignore the nav grid,", - "never collide with ground units, and can only be hit by weapons listing \"air\" in `targets` \u2014 so", - "a roster with air units MUST keep some ground weapon anti-air capable or there is no answer to", - "them. `domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged", + "never collide with ground units, and while AIRBORNE can only be hit by weapons listing \"air\"", + "in `targets` \u2014 so a roster with air units MUST keep some ground weapon anti-air capable or", + "there is no answer to them. That protection comes from flying, not from having wings: a landed", + "aircraft is an ordinary ground target that anything can shoot, and it cannot fire back.", + "`domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged", "`\"air\": true`. Terrain passability comes from the move class COST TABLE, not from the terrain's", "own `blocksMove` \u2014 that is how `hover` crosses water without a per-terrain exception." ], @@ -33,6 +35,7 @@ "pathBudgetPerTick": 8, "retargetPeriodTicks": 10, "engageHoldFraction": 0.45, + "siegeHoldFraction": 0.95, "separationStiffness": 0.55, "shoveAfterSec": 0.6, "stuckGiveUpSec": 3.0, @@ -901,12 +904,15 @@ "airfield" ], "weapons": [ - "aacannon" + "aacannon", + "pintlegun" ], "flight": { "height": 26, "takeoffSec": 0.45, - "landSec": 0.7 + "landSec": 0.7, + "overshoot": 420, + "keepout": 700 }, "sheetSlot": "unitSheet", "frame": 12, @@ -942,7 +948,9 @@ "flight": { "height": 30, "takeoffSec": 0.7, - "landSec": 1.0 + "landSec": 1.0, + "overshoot": 540, + "keepout": 700 }, "sheetSlot": "unitSheet", "frame": 13, @@ -1131,7 +1139,7 @@ "w": 1, "h": 1 }, - "hp": 3300, + "hp": 1700, "armorClass": "structure", "sight": 300, "cost": { @@ -1162,7 +1170,7 @@ "w": 2, "h": 2 }, - "hp": 4700, + "hp": 3100, "armorClass": "structure", "sight": 420, "cost": { diff --git a/src/games/totalannihilation/TAAI.js b/src/games/totalannihilation/TAAI.js index 60f5cbe..b7a928c 100644 --- a/src/games/totalannihilation/TAAI.js +++ b/src/games/totalannihilation/TAAI.js @@ -10,7 +10,7 @@ // Difficulty is decision quality, reaction time and an orders-per-second cap — not // income cheating. A cheating AI's win rate would tell us nothing about the balance. -import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt } from './TALogic.js'; +import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt, angleDelta } from './TALogic.js'; import { canEngage } from './TARules.js'; import { worldToTileX, worldToTileY } from './TANav.js'; @@ -36,7 +36,7 @@ function memFor(state, armyIdx) { nextThinkTick: 0, ordersThisSecond: 0, secondMark: 0, squad: [], squadPeak: 0, phase: 'build', focusId: 0, - scoutId: 0, airPreyId: 0, + scoutId: 0, airPreyId: 0, lastThreatTick: -99999, knownEnemies: [], // [{x,y,defId,isBuilding,tick}] lastAttackTick: -99999, buildSpiral: 0, @@ -122,11 +122,30 @@ function observe(ctx) { const byId = new Map(mem.knownEnemies.map((k) => [k.id, k])); for (const s of seen) byId.set(s.id, s); // Forget anything we haven't seen in two minutes, and anything we can see is gone. + // + // BUILDINGS are exempt from the timer: they do not move, so a remembered position stays + // correct until the thing itself dies, and `live` already covers that. Ageing them out was + // making the AI lose track of a base it had already found, fall back to blind sweeping, and + // fail to finish games it had overwhelmingly won — one skirmish ended the clock with 30 + // units against three. const cutoff = state.tick - ctx.rules.constants.tickHz * 120; const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id)); mem.knownEnemies = [...byId.values()] - .filter((k) => k.tick >= cutoff && live.has(k.id)) + .filter((k) => (k.isBuilding || k.tick >= cutoff) && live.has(k.id)) .sort((a, b) => a.id - b.id); + + // When did something hostile last get inside the base perimeter? Static defence is a + // REACTION in this AI — built because the base is being raided, not on a timer — so this + // one number is what the whole defensive build-out hangs off. + if (mem.baseSet) { + const r = state.tileSize * 16; + for (const s of seen) { + if (s.isBuilding) continue; + if (Math.hypot(s.x - mem.baseX, s.y - mem.baseY) > r) continue; + mem.lastThreatTick = state.tick; + break; + } + } } function gatherOwn(ctx) { @@ -176,7 +195,7 @@ function manageEconomyAndBase(ctx, mine) { const want = chooseBuilding(ctx, mine); if (!want) return; - const spot = findPlacement(ctx, mine, want, builder); + const spot = findPlacement(ctx, mine, want, builder, placementOptionsFor(ctx, want)); if (!spot) return; order(ctx, { @@ -203,6 +222,12 @@ function chooseBuilding(ctx, mine) { if (n('massgen') < 2) return mGen; if (n('vehicleplant') < 1) return plant; + // Something is in the base and there is nothing static shooting back at it. This one jumps + // the whole economic queue: a raid that is happening now is not answered by another + // generator, and a base with no anti-air at all is farmed indefinitely by a single bomber. + const urgent = urgentDefence(ctx, mine); + if (urgent) return urgent; + // 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. @@ -216,6 +241,26 @@ function chooseBuilding(ctx, mine) { return null; } + // Exactly one Airfield, once there is a real economy and the ground factories are already + // standing. Air is a SECOND front, not a substitute for the first: gated any earlier, a + // skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its barracks + // were being overrun, and lost campaign missions it had been winning. Skills 1-2 never reach + // this branch, so air stays a mark of a competent opponent. + // + // Gated on STRUCTURE COUNTS, and placed ahead of the ratio governor below on purpose. Two + // earlier attempts keyed on mass income never fired in practice: income peaks between 11 and + // 27 across skirmishes, and while a high-expansion AI is spending hard it is almost always + // stalling slightly, so the generator rules below would win every single time. A skill-5 AI + // built an Airfield in zero games out of six before this moved. + // Measured across 24 skirmishes: generators peak at 4-10 energy and 6-10 mass, so those two + // counts are reachable in nearly every game, while `buildEff > 0.7` held for as little as + // 20% of ticks in the games where the AI was spending hardest. 0.5 still refuses to start an + // 1800-energy structure in a real brownout without gating air behind a placid economy. + if (airfield && n('airfield') < 1 && n('barracks') + n('vehicleplant') >= 2 + && n('energygen') >= 4 && n('massgen') >= 3 && army.buildEff > 0.5) { + return airfield; + } + // 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. @@ -244,15 +289,9 @@ function chooseBuilding(ctx, mine) { const supported = Math.floor(army.mIncome / groundDrain); if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory(); - // Exactly one Airfield, and only once the ground factories the economy can actually feed - // are already standing. Air is a SECOND front, not a substitute for the first: gated any - // earlier, a skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its - // barracks were being overrun, and lost campaign missions it had been winning. Skills 1-2 - // never reach this branch at all, so air stays a mark of a competent opponent. - if (airfield && n('airfield') < 1 && n('barracks') + n('vehicleplant') >= 2 - && army.mIncome >= groundDrain * 2.5 && army.buildEff > 0.95) { - return airfield; - } + // Steady-state defence, once production capacity is where the economy can feed it. + const defence = steadyDefence(ctx, mine); + if (defence) return defence; if (ratio > TARGET_RATIO && roomM) return mGen; if (ratio < TARGET_RATIO && roomE) return eGen; @@ -262,6 +301,83 @@ function chooseBuilding(ctx, mine) { return null; } +/** + * Static defence is a skill-4-and-up behaviour, like the Airfield is skill 3 and up. + * + * These structures hit hard for their cost — a Laser Tower puts out 130 dps behind 1700 hp for + * 60% of a Tank's price — and the ground roster has no dedicated siege answer to them yet, so + * two equally-matched AIs both digging in grind rather than resolve. Reserving them for the + * stronger opponents puts that pressure where a player goes looking for it and leaves the low + * end of the ladder playing the open game it was tuned for. Drop this to 3 (or 1) once there is + * a unit whose job is cracking fortifications. + */ +const DEFENCE_MIN_SKILL = 4; + +/** Has anything hostile been inside the base perimeter recently? */ +function underThreat(ctx) { + return ctx.state.tick - (ctx.mem.lastThreatTick ?? -99999) < ctx.rules.constants.tickHz * 90; +} + +/** Have we actually SEEN enemy aircraft? Fog-limited, like every other AI decision. */ +function enemyAirSeen(ctx) { + return ctx.mem.knownEnemies.some((k) => ctx.rules.defById[k.defId]?.isAir); +} + +/** + * The one structure the base needs RIGHT NOW, ahead of any economic consideration. Kept + * deliberately tiny — one tower against a raid, one launcher against aircraft. Everything + * beyond the first of each is a steady-state decision (see steadyDefence), because an AI that + * answers pressure by building walls stops building an army and loses slowly instead of fast. + */ +function urgentDefence(ctx, mine) { + const { rules } = ctx; + if (ctx.skill < DEFENCE_MIN_SKILL) return null; + const n = (id) => (mine.byDef[id] ?? 0); + const aa = rules.buildingById.missilelauncher; + const tower = rules.buildingById.lasertower; + // Anti-air first: a Laser Tower cannot shoot at aircraft at all, so a base being bombed gets + // nothing whatsoever from one. + if (aa && enemyAirSeen(ctx) && n('missilelauncher') < 1) return aa; + if (tower && underThreat(ctx) && n('lasertower') + n('missilelauncher') < 1) return tower; + return null; +} + +/** + * Defence build-out from surplus. Capped hard and only ever in response to something actually + * observed — this AI wins by attacking, and a turtle that spends its income on towers fields + * no army to attack with. + */ +function steadyDefence(ctx, mine) { + const { rules, expansion } = ctx; + if (ctx.skill < DEFENCE_MIN_SKILL) return null; + const n = (id) => (mine.byDef[id] ?? 0); + const aa = rules.buildingById.missilelauncher; + const tower = rules.buildingById.lasertower; + + // More launchers the more air we've seen, because that is the threat they answer. + if (aa && enemyAirSeen(ctx)) { + const airSeen = ctx.mem.knownEnemies.filter((k) => rules.defById[k.defId]?.isAir).length; + if (n('missilelauncher') < Math.min(3, 1 + Math.floor(airSeen / 2))) return aa; + } + // Two reasons to fortify: the base is being raided, or the army has marched off and left it + // uncovered. The second is the one a human plays instinctively — a committed push is exactly + // when a base is most exposed — but it buys only a token, because an AI that answers its own + // aggression with a wall of towers stops fielding the army that was winning it the game. + // Purely reactive: only a base that is actually being raided fortifies. An earlier version + // also fortified whenever the army marched out, which reads as sensible play and measured + // terribly — static defence is strong enough here that both sides digging in turned 7 of 8 + // skirmishes into unresolved slugfests. + if (!underThreat(ctx)) return null; + // Tower, then launcher, then the rest of the towers. The launcher comes second rather than + // last because it out-ranges the tower and is the only static piece that can shoot upward at + // all — a position built purely from lasers has no answer whatsoever the first time it is + // overflown, and waiting for the full tower count to finish meant it almost never got built. + if (tower && n('lasertower') < 1) return tower; + if (aa && n('missilelauncher') < 1) return aa; + if (tower && n('lasertower') < Math.round(1 + expansion * 2)) return tower; + 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); @@ -274,10 +390,9 @@ function factoryMassDrain(rules, def) { * 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. */ -function findPlacement(ctx, mine, def, builder) { +function findPlacement(ctx, mine, def, builder, opts = {}) { const { state, rules } = ctx; const mem = ctx.mem; - const ts = state.tileSize; const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x); const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y); @@ -286,23 +401,48 @@ function findPlacement(ctx, mine, def, builder) { if (spot) return spot; } - const maxR = 22; - for (let r = 2; r < maxR; r++) { - // Rotate the starting angle per attempt so the base doesn't grow as a solid slab — - // AoE chains through tightly packed buildings. - const steps = Math.max(8, r * 4); - const off = (ctx.mem.buildSpiral++ % steps); - for (let s = 0; s < steps; s++) { - const a = ((s + off) / steps) * Math.PI * 2; - const tx = bx + Math.round(Math.cos(a) * r); - const ty = by + Math.round(Math.sin(a) * r); - if (!spacedEnough(ctx, mine, tx, ty, def)) continue; - if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty }; + const minR = opts.minR ?? 2; + const maxR = opts.maxR ?? 22; + // `facing` narrows the spiral to an arc, so a Laser Tower goes on the side the attacks come + // from instead of wherever the spiral happened to have room. Tried twice: once constrained, + // then unconstrained, because a base hemmed in on that side must still get its tower up. + const facing = opts.facing ?? null; + for (const arc of facing ? [opts.arc ?? Math.PI / 3, Math.PI] : [Math.PI]) { + for (let r = minR; r < maxR; r++) { + // Rotate the starting angle per attempt so the base doesn't grow as a solid slab — + // AoE chains through tightly packed buildings. + const steps = Math.max(8, r * 4); + const off = (ctx.mem.buildSpiral++ % steps); + for (let s = 0; s < steps; s++) { + const a = ((s + off) / steps) * Math.PI * 2; + if (facing != null && Math.abs(angleDelta(a, facing)) > arc) continue; + const tx = bx + Math.round(Math.cos(a) * r); + const ty = by + Math.round(Math.sin(a) * r); + if (!spacedEnough(ctx, mine, tx, ty, def)) continue; + if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty }; + } } } return null; } +/** + * Where a given structure wants to sit. Defences are the only things with an opinion: a Laser + * Tower is a perimeter piece and belongs on the approach the enemy actually uses, while a + * Missile Launcher is protecting the base itself from something that can arrive over any edge + * of it, so it goes in among the buildings it is covering. + */ +function placementOptionsFor(ctx, def) { + if (def.id === 'lasertower') { + const t = enemyBaseGuess(ctx); + const facing = t && ctx.mem.baseSet + ? Math.atan2(t.y - ctx.mem.baseY, t.x - ctx.mem.baseX) : null; + return { minR: 5, maxR: 16, facing, arc: Math.PI / 3 }; + } + if (def.id === 'missilelauncher') return { minR: 2, maxR: 10 }; + return {}; +} + function findMetalSpot(ctx, def, bx, by) { const { state, rules } = ctx; let best = null, bestD = Infinity; @@ -482,22 +622,27 @@ function sweepTarget(ctx) { // Air superiority // --------------------------------------------------------------------------- -/** A unit whose guns can't touch the ground at all — an interceptor, not an attacker. */ -function isInterceptor(def) { - const ws = def.weaponDefs ?? []; - return ws.length > 0 && !ws.some((w) => w.targetsGround); +/** Can this def shoot at aircraft at all? */ +function canHitAir(def) { + return (def.weaponDefs ?? []).some((w) => w.targetsAir); } /** - * Interceptors get their own job. Handing them to manageMilitary would attach them to the - * ground push, where they would fly into the enemy base, be unable to shoot a single thing in - * it, and die to the first missile tower — so they hunt enemy aircraft instead, and hold over - * the base when there are none. + * Interception. Aircraft that can shoot other aircraft are pulled out of the ground push ONLY + * while there is enemy air to answer — the Fighter carries a ground gun as well now, so leaving + * it permanently on air patrol would idle half its usefulness whenever the enemy owns no + * planes. Anything not claimed here falls through to manageMilitary and joins the attack. */ function manageAir(ctx, mine) { const { state, rules, mem, armyIdx } = ctx; - const fighters = mine.units.filter((u) => isInterceptor(rules.defById[u.defId])); - if (!fighters.length) return; + if (!mem.airClaimed) mem.airClaimed = new Set(); + mem.airClaimed.clear(); + + const fighters = mine.units.filter((u) => { + const def = rules.defById[u.defId]; + return def.isAir && canHitAir(def); + }); + if (!fighters.length) { mem.airPreyId = 0; return; } let prey = null, preyD = Infinity; for (const e of state.entities) { @@ -506,31 +651,20 @@ function manageAir(ctx, mine) { const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY); if (d < preyD) { prey = e; preyD = d; } } + if (!prey) { mem.airPreyId = 0; return; } - if (prey) { - // Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue, - // so re-picking a bandit every think would restart the intercept before it ever closed. - // Only re-issue when the current one is gone or someone has fallen out of the flight. - const committed = fighters.every((u) => u.orders[0]?.type === 'attack' && u.orders[0].targetId === mem.airPreyId); - if (committed && mem.airPreyId === prey.id) return; - mem.airPreyId = prey.id; - // Send the whole flight at one bandit — aircraft trade far too fast to arrive piecemeal. - order(ctx, { - army: armyIdx, unitIds: fighters.map((u) => u.id), - order: { type: 'attack', targetId: prey.id }, - }); - return; - } - mem.airPreyId = 0; - // Nothing to intercept: park the idle ones over the base so they're already in position. - const loitering = fighters.filter((u) => !u.orders.length - && Math.hypot(u.x - mem.baseX, u.y - mem.baseY) > state.tileSize * 6); - if (loitering.length) { - order(ctx, { - army: armyIdx, unitIds: loitering.map((u) => u.id), - order: { type: 'move', x: mem.baseX, y: mem.baseY }, - }); - } + for (const u of fighters) mem.airClaimed.add(u.id); + // Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue, so + // re-picking a bandit every think would restart the intercept before it ever closed. Only + // re-issue when the current one is gone or someone has fallen out of the flight. + const committed = fighters.every((u) => u.orders[0]?.type === 'attack' && u.orders[0].targetId === mem.airPreyId); + if (committed && mem.airPreyId === prey.id) return; + mem.airPreyId = prey.id; + // Send the whole flight at one bandit — aircraft trade far too fast to arrive piecemeal. + order(ctx, { + army: armyIdx, unitIds: fighters.map((u) => u.id), + order: { type: 'attack', targetId: prey.id }, + }); } // --------------------------------------------------------------------------- @@ -545,7 +679,7 @@ function manageMilitary(ctx, mine) { if (u.id === mem.scoutId) return false; const def = rules.defById[u.defId]; if (!def.weaponDefs?.length || def.builds?.length) return false; - if (isInterceptor(def)) return false; // manageAir owns these + if (mem.airClaimed?.has(u.id)) return false; // manageAir has these on intercept return u.orders.length === 0; }); diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js index 2f83720..936ba8c 100644 --- a/src/games/totalannihilation/TAHud.js +++ b/src/games/totalannihilation/TAHud.js @@ -207,7 +207,10 @@ export default class TAHud { if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`); // Which layer a unit lives in decides whether the player's current army can even shoot // it, so it belongs in the tooltip above the individual weapon lines. - if (def.isAir) lines.push('AIRCRAFT — only anti-air weapons can hit it'); + if (def.isAir) { + lines.push('AIRCRAFT — only anti-air can hit it while flying'); + lines.push('Landed it cannot shoot, and anything can hit it'); + } else if (def.moveClass === 'hover') lines.push('HOVER — crosses water'); for (const w of def.weaponDefs ?? []) { const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only'; diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index d9479f0..42eb3e0 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -143,9 +143,24 @@ function baseEntity(state, army, def) { army, defId: def.id, isBuilding: !!def.isBuilding, - // Cached off the def because the movement, separation, targeting and projectile loops all - // branch on it every tick — this is the hottest flag in the sim. + // Two related flags, and the difference matters. + // + // `isAir` is STATIC: which layer the unit moves through. It never changes, and it is what + // movement, separation and the nav grid read — a parked aircraft still doesn't pathfind. + // + // `airborne` is DYNAMIC: whether it is currently off the ground. This is what WEAPONS + // read, so an aircraft sitting on its pad is an ordinary ground target that a rifle or a + // tank shell can hit. Being untouchable is a property of flying, not of having wings. isAir: !!def.isAir, + airborne: false, + // Altitude, 0 = on the ground, 1 = fully airborne. This lives in the SIM rather than the + // renderer because it is a rule, not a decoration: a grounded aircraft cannot fire, and + // its speed ramps with its height. `pliftFrac` is the previous tick's value, for the same + // reason px/py exist — the view interpolates between the two. + liftFrac: 0, pliftFrac: 0, + // Strafing run bookkeeping: once a pass goes overhead the unit commits to a point beyond + // the target and flies to it before coming about. + egressing: false, egressX: 0, egressY: 0, x: 0, y: 0, px: 0, py: 0, heading: 0, pheading: 0, turretRot: 0, pturretRot: 0, @@ -759,12 +774,197 @@ function findAutoHealTarget(state, rules, e, def) { return best; } +const MOVE_LIKE = new Set(['move', 'attackMove', 'patrol']); + +/** + * How far into its own weapon envelope a unit closes before stopping to shoot a building. + * + * A building that SHOOTS BACK gets the near-maximum fraction, so anything out-ranging it can + * stand off and dismantle it for free — which is the entire counterplay to a defensive + * structure, and the reason a Rocket Tank (440) exists alongside a Tank (350) against a Laser + * Tower (340). Everything else keeps the low fraction that was tuned for base assaults: + * measured at 64 games, closing all the way in ran the skill ladder at 90% and holding at + * range dropped it to 68%, so this stays narrowly scoped to targets that can hurt you. + */ +function holdFractionFor(rules, target) { + const c = rules.constants; + const def = rules.defById[target.defId]; + return def?.weaponDefs?.length ? (c.siegeHoldFraction ?? 0.95) : (c.engageHoldFraction ?? 0.85); +} + +/** A point `dist` ahead of where `e` is pointing. */ +function forwardPoint(e, dist) { + return { x: e.x + Math.cos(e.heading) * dist, y: e.y + Math.sin(e.heading) * dist }; +} + +/** Nearest live hostile building within `radius` of (x,y), or null. */ +function hostileBuildingNear(state, rules, army, x, y, radius) { + let best = null, bestD = radius; + for (const t of state.entities) { + if (t.dead || !t.isBuilding || t.army === army || !state.armies[t.army]) continue; + const d = surfaceDist(rules, { x, y }, t); + if (d < bestD) { best = t; bestD = d; } + } + return best; +} + +/** + * Where a strafing aircraft should point right now, given the thing it is attacking. + * + * The run has two legs. INBOUND steers straight at the target and holds until the aircraft is + * genuinely overhead; EGRESS carries on to a point `overshoot` past it, which is what gives + * the pass its follow-through and the room to come about. The arc of the turn is not scripted + * — it falls out of the unit's turnRate against a speed that never drops. + */ +function strafePoint(state, rules, e, def, t) { + const reach = Math.max(64, e.radius * 2); + if (e.egressing) { + if (Math.hypot(e.egressX - e.x, e.egressY - e.y) > reach) return { x: e.egressX, y: e.egressY }; + e.egressing = false; + } + // Two ways to know the pass is spent: overhead, or the target has slipped behind us. The + // second matters because a Fighter covers 15px per tick and can cross a small overhead + // radius between two samples without ever testing inside it. + const bearing = Math.atan2(t.y - e.y, t.x - e.x); + const behind = Math.abs(angleDelta(e.heading, bearing)) > 2.0; + const dist = Math.hypot(t.x - e.x, t.y - e.y); + const overhead = surfaceDist(rules, e, t) <= Math.max(40, e.radius); + if (e.liftFrac >= 1 && (overhead || (behind && dist < def.flight.overshoot))) { + e.egressing = true; + const p = forwardPoint(e, def.flight.overshoot); + // Clamped inside the world, or a run started near the edge parks the aircraft against the + // map boundary waiting to reach a point it can never get to. + e.egressX = Math.max(e.radius, Math.min(state.worldW - e.radius, p.x)); + e.egressY = Math.max(e.radius, Math.min(state.worldH - e.radius, p.y)); + return { x: e.egressX, y: e.egressY }; + } + return { x: t.x, y: t.y }; +} + +/** + * Orders for an aircraft that cannot stop in the air. + * + * The whole shape of this is: it is either LANDED (still, and unable to shoot) or FLYING (at + * cruise, always going somewhere). There is no third option, so every order resolves to a + * steering point plus a decision about whether to be off the ground at all. + */ +function stepStraferOrders(state, rules, e, def) { + const keepout = def.flight.keepout; + e.buildTargetId = 0; + + // A move into a hostile base is not a move, it is an attack run. Landing there would put + // the aircraft stationary and defenceless under the guns of whatever it flew to. + const first = e.orders[0]; + if (first && MOVE_LIKE.has(first.type)) { + const dest = orderDestination(e, first); + const foe = hostileBuildingNear(state, rules, e.army, dest.x, dest.y, keepout); + if (foe) e.orders[0] = { type: 'attack', targetId: foe.id }; + } + + const order = e.orders[0]; + if (!order) { + // Idle: put down where it stands, unless that is inside somebody's base — then clear out + // first and land once there is room. + const foe = hostileBuildingNear(state, rules, e.army, e.x, e.y, keepout); + if (foe) { + e._wantAirborne = true; + const dx = e.x - foe.x, dy = e.y - foe.y; + const d = Math.hypot(dx, dy) || 1; + e.movingTo = { x: e.x + (dx / d) * keepout, y: e.y + (dy / d) * keepout }; + } else { + e._wantAirborne = false; + e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null; + } + return; + } + + switch (order.type) { + case 'stop': + e.orders.length = 0; e.targetId = 0; e.egressing = false; e.movingTo = null; + break; + + case 'hold': + // For something that cannot hover, holding a position means being on the ground at it. + e._wantAirborne = false; + e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null; + break; + + case 'attack': + case 'guard': { + const t = entityById(state, order.targetId); + if (!t || (order.type === 'attack' && !canEngage(def, t))) { + e.orders.shift(); e.egressing = false; e.targetId = 0; + break; + } + e._wantAirborne = true; + e.movingTo = strafePoint(state, rules, e, def, t); + break; + } + + case 'move': + case 'attackMove': + case 'patrol': { + const dest = orderDestination(e, order); + const d = Math.hypot(dest.x - e.x, dest.y - e.y); + const arrive = Math.max(rules.constants.arriveSlackPx, e.radius * 2); + e.movingTo = dest; + if (d > arrive) { e._wantAirborne = true; break; } + if (order.type === 'patrol') { + order.leg = order.leg === 0 ? 1 : 0; + e._wantAirborne = true; + e.movingTo = orderDestination(e, order); + break; + } + // Arrived. Settle onto the spot — speed ramps down with altitude in stepMovement, so + // this reads as an approach rather than a stop, and the order only clears once down. + e._wantAirborne = false; + if (e.liftFrac <= 0) { + e.orders.shift(); e.movingTo = null; e.egressing = false; + } + break; + } + + default: + e.orders.shift(); + } +} + +/** + * Advance every flight unit's altitude toward where its orders want it. Runs after stepOrders + * (which sets `_wantAirborne`) and before stepMovement (which scales speed by the result). + */ +function stepFlight(state, rules) { + const dt = rules.dt; + for (const e of state.entities) { + if (e.dead || e.isBuilding || e.site) continue; + const def = defOf(rules, e); + if (!def.flight) continue; + // Units that merely hover are up whenever they have something to do — they are allowed to + // stop in mid-air, so their altitude is decoration rather than a rule. + if (!def.strafes) e._wantAirborne = !!(e.orders.length || e.movingTo || e.targetId); + e.pliftFrac = e.liftFrac; + const want = e._wantAirborne ? 1 : 0; + const step = dt / (want ? def.flight.takeoffSec : def.flight.landSec); + // Note the explicit "already there" case. Folding it into the descend branch makes a unit + // holding altitude drop a step and climb it back every other tick, which flickers both the + // sprite and — because cruise speed is scaled by this — the unit's actual velocity. + if (want > e.liftFrac) e.liftFrac = Math.min(want, e.liftFrac + step); + else if (want < e.liftFrac) e.liftFrac = Math.max(want, e.liftFrac - step); + // The instant the wheels leave the ground it becomes an air target, and the instant they + // touch down it stops being one. + e.airborne = e.isAir && e.liftFrac > 0; + } +} + function stepOrders(state, rules) { const ts = state.tileSize; for (const e of state.entities) { if (e.dead || e.site) continue; if (e.isBuilding) continue; const def = defOf(rules, e); + // An armed aircraft plays by a different rulebook — it has no stationary state in the air, + // so none of the arrive/hold/close-to-range logic below applies to it. + if (def.strafes) { stepStraferOrders(state, rules, e, def); continue; } const order = e.orders[0]; if (!order) { @@ -835,7 +1035,7 @@ function stepOrders(state, rules) { if (order.type === 'attackMove' && def.maxRange > 0) { const foe = entityById(state, e.targetId); if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army] - && surfaceDist(rules, e, foe) <= def.maxRange * (rules.constants.engageHoldFraction ?? 0.85)) { + && surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, foe)) { e.path = null; e.movingTo = null; e.stuckTicks = 0; @@ -870,7 +1070,9 @@ function stepOrders(state, rules) { if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; } e.targetId = target.id; const d = surfaceDist(rules, e, target); - const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85); + const wantRange = def.maxRange * (target.isBuilding + ? holdFractionFor(rules, target) + : (rules.constants.engageHoldFraction ?? 0.85)); if (d > wantRange) { if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y); e.movingTo = { x: target.x, y: target.y }; @@ -967,7 +1169,22 @@ function stepMovement(state, rules) { tx = e.movingTo.x; ty = e.movingTo.y; } - if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; } + if (tx == null) { + // A stationary turretless unit can otherwise fire only within the arc it happens to be + // facing, because nothing ever turns it. That made infantry anti-air close to + // decorative: a strafing aircraft crosses that window in well under a second, and eight + // rocket troopers got off four shots in a minute. + // + // Restricted to AIR targets on purpose. Allowing it against ground targets as well is + // arguably more correct, but measured over the campaign it strengthens massed infantry + // on defence enough to flip m06 from winnable to not — a ground-balance change has no + // business riding along inside an anti-air fix. + if (!e.isBuilding && e.targetId && def.turretFrame == null && def.maxRange > 0) { + const t = entityById(state, e.targetId); + if (t && t.airborne) e.heading = turnToward(e.heading, Math.atan2(t.y - e.y, t.x - e.x), def.turnRate * dt); + } + e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; + } const dx = tx - e.x, dy = ty - e.y; const dist = Math.hypot(dx, dy); @@ -985,13 +1202,26 @@ function stepMovement(state, rules) { terrainMul = tcost ? 1 / tcost : 1; } - // Slow down while still swinging round, and ease into the final waypoint. - const misalign = Math.abs(angleDelta(e.heading, want)); - const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1); - const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1; - - const speed = def.speed * terrainMul * turnMul * arriveMul; - const step = Math.min(speed * dt, dist); + let speed, step; + if (def.strafes) { + // A strafing aircraft holds cruise speed no matter what it is doing: the ONLY thing that + // changes in the air is its heading, which is what turns a course correction into an arc + // instead of a pirouette. Speed tracks altitude, so it accelerates off the pad and slows + // into a landing, and it is never allowed to creep or hang. + speed = def.speed * e.liftFrac; + // No clamp to the remaining distance while airborne — clamping is what would let it stop + // dead on top of its target instead of carrying through the pass. It does clamp on the + // way down, so a landing settles on its spot rather than skidding past it. + step = e.liftFrac >= 1 ? speed * dt : Math.min(speed * dt, dist); + } else { + // Slow down while still swinging round, and ease into the final waypoint. + const misalign = Math.abs(angleDelta(e.heading, want)); + const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1); + const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1; + speed = def.speed * terrainMul * turnMul * arriveMul; + step = Math.min(speed * dt, dist); + } + if (step <= 0) { e.vx = 0; e.vy = 0; continue; } e.vx = Math.cos(e.heading) * step / dt; e.vy = Math.sin(e.heading) * step / dt; e.x += Math.cos(e.heading) * step; @@ -1168,11 +1398,20 @@ function stepCombat(state, rules) { const def = defOf(rules, e); if (!def.weaponDefs?.length) continue; + // Reloads run down on the pad too, so an aircraft rearms while it is grounded. for (let wi = 0; wi < e.reload.length; wi++) if (e.reload[wi] > 0) e.reload[wi]--; + // A grounded aircraft cannot shoot. This is the flip side of not being able to hover: + // sitting still and being useful are mutually exclusive for anything with wings. + if (def.flight && e.liftFrac < 1) { e.targetId = 0; continue; } + // Staggered acquisition — the single biggest CPU lever in the sim. Re-scan only every // retargetPeriodTicks, phase-shifted per unit so the cost spreads across ticks. - const holdOrder = e.orders[0]?.type === 'attack'; + // + // A strafing aircraft is deliberately NOT pinned to its ordered target. Its orders decide + // where it flies; its guns take whatever is in range on the way past, which is the whole + // point of a unit that crosses the battlefield rather than parking in front of one thing. + const holdOrder = e.orders[0]?.type === 'attack' && !def.strafes; let target = entityById(state, e.targetId); if (target && !inWeaponRange(rules, e, def, target)) target = null; const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0; @@ -1206,7 +1445,7 @@ function stepCombat(state, rules) { 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; - if (!weaponHitsDomain(w, target.isAir)) continue; + if (!weaponHitsDomain(w, target.airborne)) continue; const dist = surface; if (dist > w.range || dist < (w.minRange ?? 0)) continue; if (!aligned) continue; @@ -1226,7 +1465,7 @@ function stepCombat(state, rules) { for (let wi = 0; wi < def.weaponDefs.length; wi++) { const w = def.weaponDefs[wi]; if (w.manual) continue; - if (!weaponHitsDomain(w, target.isAir)) continue; + if (!weaponHitsDomain(w, target.airborne)) continue; if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) { fireWeapon(state, rules, e, w, target, facing, aimPt); e.burstLeft[wi]--; @@ -1285,7 +1524,7 @@ function acquireTarget(state, rules, e, def, hash, near) { for (const w of def.weaponDefs) { // A weapon that can't reach the target's domain contributes nothing, so a unit with no // AA at all never scores an aircraft and simply ignores it. - if (!weaponHitsDomain(w, t.isAir)) continue; + if (!weaponHitsDomain(w, t.airborne)) continue; if (d > w.range || d < (w.minRange ?? 0)) continue; const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1; const s = (w.damage * mul) / (1 + d / w.range); @@ -1413,7 +1652,7 @@ function stepProjectiles(state, rules) { let bestT = Infinity, hit = null; for (const e of state.entities) { if (e.dead || e.site || e.army === p.army) continue; - if (!weaponHitsDomain(w, e.isAir)) continue; + if (!weaponHitsDomain(w, e.airborne)) continue; 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; } @@ -1430,7 +1669,7 @@ function stepProjectiles(state, rules) { else { for (const e of state.entities) { if (e.dead || e.army === p.army) continue; - if (!weaponHitsDomain(w, e.isAir)) continue; + if (!weaponHitsDomain(w, e.airborne)) continue; if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId }); break; @@ -1467,7 +1706,7 @@ function applyAoe(state, rules, x, y, w, source) { if (e.dead) continue; if (e.army === source.army && !rules.constants.friendlyFire) continue; // Splash respects the domain too — a bomb's blast radius stays on the ground. - if (!weaponHitsDomain(w, e.isAir)) continue; + if (!weaponHitsDomain(w, e.airborne)) continue; // Falloff is measured to the SURFACE, so a shell that lands squarely on a building's // face counts as a direct hit wherever along that face it strikes. Measuring from the // centre circle instead scored an off-axis wall hit as 32px away and cut its damage by @@ -1533,7 +1772,7 @@ function killEntity(state, rules, e, source) { state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius }); for (const o of state.entities) { if (o.dead || o.id === e.id) continue; - if (o.isAir) continue; // a ground blast doesn't reach whatever is flying over it + if (o.airborne) continue; // a ground blast doesn't reach whatever is flying over it const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius; if (d > ex.radius) continue; const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7; @@ -1641,6 +1880,7 @@ export function tick(state, rules) { stepEconomy(state, rules); stepOrders(state, rules); + stepFlight(state, rules); servicePathQueue(state, rules); stepMovement(state, rules); stepSeparation(state, rules); @@ -1743,6 +1983,10 @@ export function serialize(state) { lastDamagedTick: e.lastDamagedTick, reload: e.reload, burstLeft: e.burstLeft, terrainBonus: e.terrainBonus, + // Altitude decides whether a unit may fire, so a save that dropped it would resume with + // every aircraft grounded and mute for a second. + liftFrac: Math.round(e.liftFrac * 1000) / 1000, + egressing: e.egressing, egressX: e.egressX, egressY: e.egressY, })), // Projectiles are sub-second transients; dropping them costs nothing and saves bytes. }); @@ -1790,6 +2034,10 @@ export function deserialize(rules, raw) { const e = baseEntity(state, s.army, def); Object.assign(e, s); e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot; + e.pliftFrac = e.liftFrac; + // Derived from the restored altitude, so a save resumes with aircraft in the same domain + // they were in rather than every one of them briefly shootable by anything. + e.airborne = e.isAir && e.liftFrac > 0; e.radius = def.isBuilding ? def.radius : (def.radius ?? 16); e.path = null; e.pathIdx = 0; e.wantPath = false; state.entities.push(e); diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index d0c6de3..6856c61 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -251,6 +251,10 @@ export function compileRules(json) { // its x/y say it is for movement, collision, targeting and clicking. It is declared // separately from `domain` on purpose: the Hover Constructor floats visibly but is a // ground unit that rifles can hit and that has to path around cliffs. + // An armed aircraft STRAFES: it cannot stop in the air, so it makes firing passes and + // lands to sit still. An unarmed one (or a merely hovering ground unit like the Hover + // Constructor) keeps the simple stop-where-you-are behaviour. + u.strafes = u.isAir && (u.weapons ?? []).length > 0; if (u.flight) { for (const k of ['height', 'takeoffSec']) { if (!(u.flight[k] > 0)) fail(`unit "${u.id}" flight.${k} must be positive`); @@ -259,6 +263,13 @@ export function compileRules(json) { fail(`unit "${u.id}" flight.landSec must be positive when present`); } u.flight.landSec = u.flight.landSec ?? u.flight.takeoffSec; + if (u.strafes) { + // How far past the target it carries before turning about, and how close to a hostile + // structure it is willing to put down. Both are distances in pixels. + for (const k of ['overshoot', 'keepout']) { + if (!(u.flight[k] > 0)) fail(`strafing unit "${u.id}" needs a positive flight.${k}`); + } + } } else if (u.isAir) { fail(`air unit "${u.id}" needs a flight block, or it will render sitting on the ground`); } @@ -410,9 +421,12 @@ export function weaponHitsDomain(weapon, targetIsAir) { * Does `def` carry ANY weapon that can reach `target`'s domain? Anything that can't should * never acquire, chase or fire at it — a rifleman ordered to attack a Fighter would otherwise * follow it across the map forever, never able to shoot. + * + * Reads `airborne`, not `isAir`: a landed aircraft is an ordinary ground target, so this flips + * for the same unit as it takes off and touches down. */ export function canEngage(def, target) { - const air = !!target.isAir; + const air = !!target.airborne; for (const w of def.weaponDefs ?? []) if (weaponHitsDomain(w, air)) return true; return false; } diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 9095628..0d51a18 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -33,10 +33,10 @@ export const DEPTHS = { // Altitude, for units whose def carries a `flight` block (everything the Airfield builds). // -// Entirely view-side. The simulation has no height axis: the entity stays exactly where its -// x/y say it is for movement, collision, targeting and clicking, and only the SPRITE is -// lifted. That is what keeps this free — no extra sim work, no state to serialize, and a unit -// you can still click where you see its shadow. +// The height itself is simulation state (`e.liftFrac`) because it gates whether a unit may +// fire; the renderer only turns that number into pixels. The entity's x/y stay on the ground +// for movement, collision, targeting and clicking — only the SPRITE is lifted — so a unit is +// still exactly where you see its shadow. // // The shadow stays pinned to the true ground position while the body rises off it, so the gap // between the two IS the altitude cue. At rest they are coincident and the shadow is fully @@ -53,13 +53,7 @@ const PIE_QUEUE_COLOR = 0xffd27a; // amber — the batch const PIE_JOB_COLOR = 0x8ad4ff; // build blue, same language as sites and nanolathe const TAU = Math.PI * 2; -/** Move `cur` toward `want` by at most `step`. */ -function approach(cur, want, step) { - if (cur < want) return Math.min(want, cur + step); - return Math.max(want, cur - step); -} - -/** Smoothstep, so a takeoff eases out of the ground and into the hover instead of snapping. */ +/** Smoothstep, so a takeoff eases out of the ground and into the climb instead of snapping. */ function smoothstep(t) { return t * t * (3 - 2 * t); } export default class TAWorldView { @@ -417,14 +411,10 @@ export default class TAWorldView { /** * @param {number} alpha interpolation factor between the last two sim ticks - * @param {number} [deltaMs] real frame time, for framerate-independent view animation * @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space */ - render(alpha, deltaMs = 16.7) { + render(alpha) { const { state, rules } = this; - // Clamped: a tab that was backgrounded for ten seconds must not teleport every aircraft - // to full altitude in one frame. - const dtSec = Math.min(0.1, Math.max(0, deltaMs) / 1000); this._updateChunks(); if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; } @@ -449,15 +439,12 @@ export default class TAWorldView { const y = e.py + (e.y - e.py) * alpha; const heading = lerpAngle(e.pheading, e.heading, alpha); - // Altitude. The trigger is deliberately "has been given something to do" rather than - // "is physically moving": e.orders fills the instant the order is issued, a tick before - // stepMovement has translated the unit anywhere, so the climb visibly leads the move - // instead of trailing it. A unit standing on a target it is shooting stays up too. + // Altitude comes from the SIM — it decides whether a unit may fire, so the renderer must + // not run a second copy of it that could disagree. Interpolated between ticks exactly + // like position, then eased so a takeoff rolls off the ground instead of snapping. let lift = 0; if (def.flight) { - const airborne = !!(e.movingTo || e.orders.length || e.targetId); - const secs = airborne ? def.flight.takeoffSec : def.flight.landSec; - s.alt = approach(s.alt, airborne ? 1 : 0, dtSec / secs); + s.alt = e.pliftFrac + (e.liftFrac - e.pliftFrac) * alpha; lift = def.flight.height * smoothstep(s.alt); } diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index b08ae02..82ac67b 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -241,9 +241,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene { for (const ev of events) this._onSimEvent(ev); - // Real frame delta, NOT delta * simSpeed — takeoff is a view animation and should look the - // same whether the match is paused, at 1x or at 4x. - const { nanoLinks, projectiles } = this.view.render(st.alpha, delta); + 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); diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md index b37f777..85e69d2 100644 --- a/src/games/totalannihilation/sprites.md +++ b/src/games/totalannihilation/sprites.md @@ -81,10 +81,20 @@ movement, collision, targeting and clicking: | Hover Constructor | 9px | 0.35s | 0.5s | At rest the unit sits flat on the ground with no visible shadow. The moment it is given -something to do, the renderer eases the **sprite** upward by `height` while leaving a black, -50%-opacity copy of its own frame pinned to the true ground position — the growing gap between -the two is the altitude. It settles back down when idle. `height` is the one number to tune if -a unit reads as too glued down or too detached. +something to do, the **sprite** rises by `height` while a black, 50%-opacity copy of its own +frame stays pinned to the true ground position — the growing gap between the two is the +altitude. It settles back down when idle. `height` is the one number to tune if a unit reads as +too glued down or too detached. + +The height itself is **simulation state** (`e.liftFrac`), not a render animation, because it +decides whether a unit is allowed to fire. The renderer only interpolates it between ticks. + +Armed aircraft additionally carry `overshoot` and `keepout` (both in pixels) and are flagged +`strafes` by the rules compiler. They cannot stop in the air: a move order ends in a landing, +an attack order becomes a repeating firing pass, and a landed one can neither shoot nor claim +the protection of the air domain — on the ground it is an ordinary target that any weapon can +hit. The Hover Constructor is exempt — it is a ground unit that merely floats, and it does not +attack. Three consequences for the art: diff --git a/tools/genTACampaign.js b/tools/genTACampaign.js index 208b37a..e6997f2 100644 --- a/tools/genTACampaign.js +++ b/tools/genTACampaign.js @@ -31,7 +31,9 @@ const CH = rules.terrain.map((t) => t.ch); // // 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. +// 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', @@ -70,7 +72,11 @@ const MISSIONS = [ defeat: 'KLAXON out-produced us and it showed.', }, { - id: 'm05', name: 'Tropic Vice', seed: 55930, size: 'medium', theme: 'tropics', symmetry: 'mirror-x', + // 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: [ diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index 9ec5980..867ad87 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -313,13 +313,12 @@ section('2c. View layering and first frame'); } // --------------------------------------------------------------------------- -section('2e. Takeoff, hover and shadow'); +section('2e. Altitude rendering'); // --------------------------------------------------------------------------- { - // Altitude is a pure view animation, so it is tested the way §2c tests layering: by driving - // the REAL TAWorldView against the stub scene and reading the sprites back. The sim is - // never ticked here — orders are set and frames are rendered — which is itself the point: - // if any of this leaked into TALogic these fixtures could not work at all. + // Height is SIMULATION state (`e.liftFrac`) because it decides whether a unit may fire. This + // section only checks that the renderer turns that number into the right pixels — the flight + // behaviour itself is §6g, where it can be tested headlessly like everything else. check('the Hover Constructor hovers without being an air unit', !!rules.unitById.hoverconstructor.flight && rules.unitById.hoverconstructor.isAir === false, 'the visual and the simulation domain must stay separate'); @@ -329,92 +328,60 @@ section('2e. Takeoff, hover and shadow'); } const map = generateMap(rules, { seed: 4242, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 }); - const FRAME = 1000 / 60; - - /** Fresh view + a single grounded fighter, with fog off so visibility never masks a bug. */ - const rig = () => { - const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] }); - const scene = makeStubScene(); + const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] }); + const scene = makeStubScene(); + try { const view = new TAWorldView(scene, rules, artJson, st, 0); view.setFogEnabled(false); + const def = rules.unitById.fighter; const start = st.starts.find((x) => x.army === 0); const e = L.spawnUnit(st, rules, 0, 'fighter', start.x * st.tileSize, start.y * st.tileSize); - const run = (sec, frameMs = FRAME) => { - for (let t = 0; t < sec * 1000; t += frameMs) view.render(0, frameMs); - }; - return { st, view, e, run, spr: () => view.sprites.get(e.id) }; - }; + const run = (sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, rules); }; - let r = rig(); - try { - const def = rules.unitById.fighter; - r.run(1.0); - let s = r.spr(); - check('an idle aircraft renders on the ground', Math.abs(s.img.y - r.e.y) < 0.01, - `body ${(s.img.y - r.e.y).toFixed(2)}px off the ground`); + run(0.2); + view.render(0); + let s = view.sprites.get(e.id); + check('a grounded aircraft renders on the ground', Math.abs(s.img.y - e.y) < 0.01, + `body ${(s.img.y - e.y).toFixed(2)}px off the ground`); check('a grounded aircraft casts no visible shadow', s.shadow.alpha < 0.001, `alpha ${s.shadow.alpha.toFixed(3)}`); - // Giving it somewhere to be is what starts the climb — orders fill a tick before the sim - // has moved it anywhere, so the takeoff leads the movement rather than trailing it. - L.issueOrder(r.st, rules, { - army: 0, unitIds: [r.e.id], order: { type: 'move', x: r.e.x + 600, y: r.e.y }, - }); - r.run(0.1); - s = r.spr(); - check('the climb starts before the unit has travelled', s.img.y < r.e.y - 0.5, - `only ${(r.e.y - s.img.y).toFixed(2)}px up after 0.1s`); - - r.run(2.0); - s = r.spr(); - const lift = r.e.y - s.img.y; - check('a moving aircraft reaches its full hover height', + L.issueOrder(st, rules, { army: 0, unitIds: [e.id], order: { type: 'move', x: e.x + 2000, y: e.y } }); + run(2.0); + // Rendered at alpha 1 — an airborne unit is in motion now, so anything comparing sprite + // positions against the entity's CURRENT x/y has to sample the end of the tick, not a + // point interpolated back toward where it was. + view.render(1); + s = view.sprites.get(e.id); + const lift = e.y - s.img.y; + check('a flying aircraft is drawn at its full hover height', Math.abs(lift - def.flight.height) < 0.5, `lifted ${lift.toFixed(1)} of ${def.flight.height}px`); check('the shadow stays pinned to the true ground position', - Math.abs(s.shadow.x - r.e.x) < 0.01 && Math.abs(s.shadow.y - r.e.y) < 0.01); + Math.abs(s.shadow.x - e.x) < 0.01 && Math.abs(s.shadow.y - e.y) < 0.01); check('the shadow separates from the body by the hover height', Math.abs((s.shadow.y - s.img.y) - def.flight.height) < 0.5); check('the airborne shadow is 50% transparent', Math.abs(s.shadow.alpha - 0.5) < 0.001, `alpha ${s.shadow.alpha.toFixed(3)}`); check('the shadow tightens as the unit climbs', s.shadow.scaleX < s.img.scaleX); - // Lifting the sprite must not re-sort it against its neighbours, or an aircraft would - // pop in front of things as it took off. - const depthUp = s.img.depth; + // Lifting the sprite must not re-sort it against its neighbours, or an aircraft would pop + // in front of things as it took off. check('depth is taken from the ground position, not the lifted sprite', - Math.abs(depthUp - (DEPTHS.air + (r.e.y / r.st.worldH) * 10 + 0.05)) < 1e-6); + Math.abs(s.img.depth - (DEPTHS.air + (e.y / st.worldH) * 10 + 0.05)) < 1e-6); - // Landing: strip the orders the way arriving would, without running the sim. - r.e.orders.length = 0; r.e.movingTo = null; r.e.targetId = 0; - r.run(2.0); - s = r.spr(); - check('an aircraft settles back onto the ground when idle', - Math.abs(s.img.y - r.e.y) < 0.01 && s.shadow.alpha < 0.001, - `body ${(s.img.y - r.e.y).toFixed(2)}px up, shadow alpha ${s.shadow.alpha.toFixed(3)}`); + // Altitude is interpolated between ticks exactly like position, so a climb is smooth at + // any framerate rather than stepping 20 times a second. Position is frozen here so the + // only thing differing between the two renders is the altitude. + e.px = e.x; e.py = e.y; + e.pliftFrac = 0; e.liftFrac = 1; + view.render(0); + const low = e.y - view.sprites.get(e.id).img.y; + view.render(1); + const high = e.y - view.sprites.get(e.id).img.y; + check('altitude interpolates between sim ticks', low < high - 1, + `alpha 0 gave ${low.toFixed(1)}px, alpha 1 gave ${high.toFixed(1)}px`); + view.destroy(); } catch (err) { - check('the takeoff animation runs', false, err.message); - } - r.view.destroy(); - - // Framerate independence. The animation advances on real elapsed time, so half a second of - // 60fps and half a second of 120fps must arrive at the same altitude — otherwise takeoff - // speed would silently depend on the player's hardware. - { - const a = rig(), b = rig(); - try { - for (const g of [a, b]) { - L.issueOrder(g.st, rules, { - army: 0, unitIds: [g.e.id], order: { type: 'move', x: g.e.x + 600, y: g.e.y }, - }); - } - a.run(0.3, 1000 / 60); - b.run(0.3, 1000 / 120); - const la = a.e.y - a.spr().img.y, lb = b.e.y - b.spr().img.y; - check('takeoff is framerate independent', Math.abs(la - lb) < 1.0, - `60fps lifted ${la.toFixed(2)}px, 120fps lifted ${lb.toFixed(2)}px`); - } catch (err) { - check('the framerate-independence rig runs', false, err.message); - } - a.view.destroy(); b.view.destroy(); + check('the altitude renderer runs', false, err.message); } } @@ -1432,34 +1399,46 @@ section('6f. Air domain and hover'); // ---- ground weapons cannot touch aircraft, and air-only weapons cannot touch the ground ---- { + // The Fighter carries the Jeep's gun for ground work, so it hurts infantry — but the + // infantry still cannot reach back, which is the asymmetry the whole domain split exists + // to create. It has to be flying for any of this: see §6g for the grounded case. + check('the air-to-air cannon cannot touch the ground', ar.weaponById.aacannon.targetsGround === false); + check('the Fighter carries the Jeep\'s gun for ground targets', + (ar.unitById.fighter.weapons ?? []).includes('pintlegun')); + const st = arena(6); const inf = []; for (let i = 0; i < 8; i++) inf.push(L.spawnUnit(st, ar, 0, 'infantry', px(8), px(14) + i * 30)); - const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(10), px(16)); + const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(14), px(16)); L.issueOrder(st, ar, { army: 0, unitIds: inf.map((u) => u.id), order: { type: 'attackMove', x: px(12), y: px(16) } }); - L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'hold' } }); + L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: inf[0].id } }); runFor(st, 60); check('rifles cannot damage a Fighter', fighter.hp === fighter.maxHp, `${fighter.hp.toFixed(0)}/${fighter.maxHp}`); - check('a Fighter cannot damage infantry', inf.every((u) => !u.dead && u.hp === u.maxHp)); + check('a Fighter strafing infantry does hurt them', + inf.some((u) => u.dead || u.hp < u.maxHp), + 'the ground gun never connected'); } { const st = arena(7); const troopers = []; for (let i = 0; i < 8; i++) troopers.push(L.spawnUnit(st, ar, 0, 'rockettrooper', px(8), px(14) + i * 30)); const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(11), px(16)); - L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'hold' } }); + L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: troopers[0].id } }); runFor(st, 60); check('shoulder rockets do reach a Fighter', fighter.dead || fighter.hp < fighter.maxHp, `${fighter.hp.toFixed(0)}/${fighter.maxHp}`); } { // Fighters are the answer to fighters; that is the whole point of an air-only weapon. + // They need somewhere to be — an idle aircraft lands, and a landed one cannot shoot. const st = arena(8); const a = L.spawnUnit(st, ar, 0, 'fighter', px(10), px(20)); - const b = L.spawnUnit(st, ar, 1, 'fighter', px(13), px(20)); + const b = L.spawnUnit(st, ar, 1, 'fighter', px(16), px(20)); + L.issueOrder(st, ar, { army: 0, unitIds: [a.id], order: { type: 'attack', targetId: b.id } }); + L.issueOrder(st, ar, { army: 1, unitIds: [b.id], order: { type: 'attack', targetId: a.id } }); runFor(st, 90); - check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp, + check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp || b.hp < b.maxHp, `${a.hp.toFixed(0)} v ${b.hp.toFixed(0)}`); } @@ -1468,12 +1447,17 @@ section('6f. Air domain and hover'); const st = arena(9); const bomber = L.spawnUnit(st, ar, 0, 'bomber', px(8), px(20)); const tank = L.spawnUnit(st, ar, 1, 'tank', px(13), px(20)); - // Parked directly over the tank: a 96px bomb blast covers it, and must not scratch it. + // Kept FLYING over the tank on a short patrol: a 96px bomb blast covers it, and must not + // scratch it while it is in the air. Idling it would land it, and a landed aircraft is + // deliberately no longer immune — see §6g. const overhead = L.spawnUnit(st, ar, 1, 'fighter', px(13), px(20)); L.issueOrder(st, ar, { army: 0, unitIds: [bomber.id], order: { type: 'attack', targetId: tank.id } }); + L.issueOrder(st, ar, { army: 1, unitIds: [overhead.id], order: { type: 'patrol', x: px(15), y: px(20) } }); runFor(st, 60); check('a Bomber damages ground armour', tank.dead || tank.hp < tank.maxHp, `${tank.hp.toFixed(0)}/${tank.maxHp}`); + check('the patrolling aircraft stayed airborne', overhead.dead || overhead.liftFrac === 1, + 'the fixture landed it, so it proves nothing about splash'); check('bomb splash does not reach the aircraft above it', overhead.hp === overhead.maxHp, `${overhead.hp.toFixed(0)}/${overhead.maxHp}`); } @@ -1483,6 +1467,10 @@ section('6f. Air domain and hover'); const st = arena(10); const inf = L.spawnUnit(st, ar, 0, 'infantry', px(10), px(20)); const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(12), px(20)); + // Airborne, so the rifleman genuinely cannot reach it. (Landed it would be a legal target; + // that is the whole point of §6g's grounded-aircraft checks.) + L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'move', x: px(30), y: px(20) } }); + L.tick(st, ar); L.issueOrder(st, ar, { army: 0, unitIds: [inf.id], order: { type: 'attack', targetId: fighter.id } }); L.tick(st, ar); check('an attack order on an unreachable domain is dropped', inf.orders.length === 0, @@ -1513,6 +1501,241 @@ section('6f. Air domain and hover'); } } +// --------------------------------------------------------------------------- +section('6g. Strafing flight'); +// --------------------------------------------------------------------------- +{ + // An armed aircraft has exactly two states: LANDED (still, and unable to shoot) or FLYING (at + // cruise, always going somewhere). Every check here is about that having no third option — + // the failure mode this replaces is a Fighter parked in mid-air over a target, sniping it + // from a standstill, which is what the previous stop-on-arrival movement produced. + const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); + raw.constants.eliminateWhenUnrecoverable = false; + const sr = compileRules(raw); + const TS = sr.constants.tileSize; + const W = 64, H = 64; + + check('armed aircraft are marked as strafing', sr.unitById.fighter.strafes === true + && sr.unitById.bomber.strafes === true); + check('the Hover Constructor is exempt from strafing', sr.unitById.hoverconstructor.strafes === false, + 'it is a ground unit that merely floats, and it does not attack'); + + const px = (t) => t * TS + TS / 2; + const arena = (seed = 3) => { + const st = L.createMatch(sr, { + seed, victory: 'annihilation', + map: { w: W, h: H, terrain: new Uint8Array(W * H).fill(sr.terrainById.ground.index), starts: [], theme: 'grasslands' }, + armies: [{ armyId: 'arm' }, { armyId: 'core' }], + }); + // Both armies need something alive or checkResult ends the match on tick one — see §6f. + L.spawnUnit(st, sr, 0, 'infantry', px(1), px(1)); + L.spawnUnit(st, sr, 1, 'infantry', px(W - 2), px(H - 2)); + st.over = null; + for (const a of st.armies) a.alive = true; + return st; + }; + const runFor = (st, sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, sr); }; + const speedOf = (e) => Math.hypot(e.x - e.px, e.y - e.py) * HZ; + + // ---- a move order ends in a landing, not a hover ---- + { + const st = arena(31); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30)); + check('a fresh aircraft starts on the ground', f.liftFrac === 0); + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(40), y: px(30) } }); + runFor(st, 1.0); + check('an ordered aircraft takes off', f.liftFrac === 1, `liftFrac ${f.liftFrac.toFixed(2)}`); + runFor(st, 30); + check('a move order ends with the aircraft landed', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`); + check('it landed where it was sent', Math.hypot(f.x - px(40), f.y - px(30)) < TS * 2, + `${(Math.hypot(f.x - px(40), f.y - px(30)) / TS).toFixed(1)} tiles away`); + check('a landed aircraft is stationary', speedOf(f) < 0.01, `${speedOf(f).toFixed(1)} px/s`); + check('a landed aircraft holds no orders', f.orders.length === 0); + } + + // ---- landed units cannot shoot ---- + { + const st = arena(32); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30)); + const foe = L.spawnUnit(st, sr, 1, 'infantry', px(32), px(30)); // well inside pintlegun range + runFor(st, 20); + check('a landed aircraft never takes off on its own', f.liftFrac === 0); + check('a landed aircraft does not shoot', foe.hp === foe.maxHp && !foe.dead, + `the target lost ${(foe.maxHp - foe.hp).toFixed(0)} hp`); + check('a landed aircraft holds no target', f.targetId === 0); + } + + // ---- a landed aircraft is an ordinary ground target ---- + { + // Immunity is a property of FLYING, not of having wings. A parked aircraft can be shot by + // anything, which is what gives the keepout rule and the choice of where to land teeth. + const st = arena(40); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30)); + const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30)); // tankgun is ground-only + L.tick(st, sr); + check('a landed aircraft is not an air target', f.airborne === false && f.isAir === true, + 'isAir describes the layer it moves in; airborne describes where it is now'); + runFor(st, 40); + check('a tank gun can destroy a parked aircraft', f.dead || f.hp < f.maxHp, + `${f.hp.toFixed(0)}/${f.maxHp}`); + } + { + // ...and the same aircraft is untouchable the moment it is off the ground. + const st = arena(41); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30)); + const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30)); + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } }); + runFor(st, 1.0); + check('taking off makes it an air target again', f.airborne === true && f.liftFrac === 1); + const hpAirborne = f.hp; + runFor(st, 25); + check('a tank gun cannot touch it once flying', f.hp === hpAirborne, + `lost ${(hpAirborne - f.hp).toFixed(0)} hp while airborne`); + check('and it kills the tank on its passes', tank.dead || tank.hp < tank.maxHp); + } + { + // Landing under guns is now a real mistake, not a free escape. + const st = arena(42); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30)); + const tank = L.spawnUnit(st, sr, 1, 'tank', px(33), px(30)); + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } }); + runFor(st, 1.0); + const wasAir = f.airborne; + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'hold' } }); // set down + runFor(st, 2.0); + check('an aircraft told to hold leaves the air domain', wasAir === true && f.airborne === false, + `liftFrac ${f.liftFrac.toFixed(2)}`); + } + + // ---- it never stops in the air ---- + { + const st = arena(33); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30)); + const foe = L.spawnUnit(st, sr, 1, 'tank', px(40), px(30)); + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: foe.id } }); + runFor(st, 3); + let slowest = Infinity, samples = 0; + for (let i = 0; i < 40 * HZ; i++) { + L.tick(st, sr); + if (f.dead || foe.dead) break; + if (f.liftFrac < 1) continue; // ignore the takeoff ramp + slowest = Math.min(slowest, speedOf(f)); + samples++; + } + const cruise = sr.unitById.fighter.speed; + check('an attacking aircraft samples its speed', samples > 100, `${samples} samples`); + check('an attacking aircraft never slows down', slowest > cruise * 0.98, + `dropped to ${slowest.toFixed(0)} of ${cruise} px/s`); + } + + // ---- the strafing run: overfly, carry through, come about, repeat ---- + { + const st = arena(34); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30)); + // A building, so it cannot run away and the geometry of the pass is unambiguous. + const tgt = L.placeBuilding(st, sr, 1, 'energygen', 40, 30); + tgt.site = false; tgt.progress = 1; tgt.hp = sr.buildingById.energygen.hp; + tgt.maxHp = tgt.hp; + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tgt.id } }); + + let closest = Infinity, farthest = 0, passes = 0, wasNear = false, egressSeen = false; + for (let i = 0; i < 60 * HZ; i++) { + L.tick(st, sr); + tgt.hp = tgt.maxHp; // immortal, so the run repeats indefinitely + if (f.liftFrac < 1) continue; + const d = Math.hypot(f.x - tgt.x, f.y - tgt.y); + closest = Math.min(closest, d); + farthest = Math.max(farthest, d); + if (f.egressing) egressSeen = true; + const near = d < TS * 3; + if (near && !wasNear) passes++; + wasNear = near; + } + check('the aircraft actually overflies its target', closest < TS * 2, + `closest approach ${(closest / TS).toFixed(1)} tiles`); + check('it carries through past the target', egressSeen && farthest > sr.unitById.fighter.flight.overshoot * 0.6, + `farthest ${farthest.toFixed(0)}px vs overshoot ${sr.unitById.fighter.flight.overshoot}`); + check('it comes about and runs in again', passes >= 3, `${passes} passes in 60s`); + check('it stays airborne throughout', f.liftFrac === 1); + } + + // ---- opportunistic fire while transiting ---- + { + const st = arena(35); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30)); + // Ordered at something far away, with a bystander parked on the flight path. + const far = L.spawnUnit(st, sr, 1, 'tank', px(55), px(30)); + const bystander = L.spawnUnit(st, sr, 1, 'infantry', px(25), px(30)); + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: far.id } }); + const startX = f.x; + runFor(st, 12); + check('a transiting aircraft shoots what it passes', + bystander.dead || bystander.hp < bystander.maxHp, 'the bystander was never engaged'); + check('shooting a bystander does not stop the aircraft', f.x > startX + TS * 8, + `only travelled ${((f.x - startX) / TS).toFixed(1)} tiles`); + } + + // ---- aircraft will not land in a hostile base ---- + { + const st = arena(36); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30)); + const base = L.placeBuilding(st, sr, 1, 'energygen', 40, 30); + base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp; + base.maxHp = base.hp; + // Told to move ONTO the enemy structure. That is not a move, it is an attack run. + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: base.x, y: base.y } }); + L.tick(st, sr); + check('a move into a hostile base becomes an attack run', + f.orders[0]?.type === 'attack' && f.orders[0].targetId === base.id, + `order is ${f.orders[0]?.type}`); + let landed = false; + for (let i = 0; i < 40 * HZ; i++) { + L.tick(st, sr); + base.hp = base.maxHp; + if (f.liftFrac === 0) { landed = true; break; } + } + check('it never puts down inside the keepout', !landed, + `landed ${(Math.hypot(f.x - base.x, f.y - base.y) / TS).toFixed(1)} tiles from the base`); + } + + // ---- an idle aircraft inside a keepout clears out, then lands ---- + { + const st = arena(37); + const base = L.placeBuilding(st, sr, 1, 'energygen', 30, 30); + base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp; + const f = L.spawnUnit(st, sr, 0, 'fighter', px(32), px(30)); // parked right next to it + runFor(st, 40); + const away = L.surfaceDist(sr, f, base); + check('an idle aircraft leaves a hostile base before settling', + away >= sr.unitById.fighter.flight.keepout * 0.9, + `only got ${away.toFixed(0)}px from the base`); + check('and then it lands', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`); + } + + // ---- the Hover Constructor keeps the old stop-in-place behaviour ---- + { + const st = arena(38); + const h = L.spawnUnit(st, sr, 0, 'hoverconstructor', px(20), px(30)); + L.issueOrder(st, sr, { army: 0, unitIds: [h.id], order: { type: 'move', x: px(26), y: px(30) } }); + runFor(st, 30); + check('a hovering builder still stops exactly on its destination', + Math.hypot(h.x - px(26), h.y - px(30)) < TS, `${(Math.hypot(h.x - px(26), h.y - px(30)) / TS).toFixed(2)} tiles off`); + check('and settles back down when idle', h.liftFrac === 0); + } + + // ---- altitude survives a save ---- + { + const st = arena(39); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30)); + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(50), y: px(30) } }); + runFor(st, 2); + const back = L.deserialize(sr, L.serialize(st)); + const rf = back?.entities.find((e) => e.defId === 'fighter'); + check('altitude survives serialization', !!rf && Math.abs(rf.liftFrac - f.liftFrac) < 0.01, + `${rf?.liftFrac} vs ${f.liftFrac}`); + } +} + // --------------------------------------------------------------------------- section('7. Fog of war'); // --------------------------------------------------------------------------- @@ -1742,6 +1965,49 @@ section('12. AI skill ladder'); } } +// --------------------------------------------------------------------------- +section('12b. AI build repertoire'); +// --------------------------------------------------------------------------- +{ + // Everything in the rules is dead data unless the AI will actually build it. Defences and + // the whole air branch were exactly that for a while: the Airfield gate was keyed on a mass + // income the AI never reaches (it peaks at 11-27, the bar was 24), so a skill-5 AI built one + // in zero games out of six while the roster claimed it could. + // + // Counts BOTH armies across several games — these are conditional behaviours, not a fixed + // build order, so the assertion is that each shows up across a sample rather than in any + // particular match. + const games = QUICK ? 6 : 12; + const built = Object.create(null); + for (let i = 0; i < games; i++) { + const map = generateMap(rules, { seed: 8000 + i * 13, size: 'medium', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 8000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + while (!st.over && st.tick < 900 * HZ) { + runAI(rules, st, 0, { skill: 4 }); + runAI(rules, st, 1, { skill: 4 }); + for (const ev of L.tick(st, rules)) { + if (ev.t === 'buildingComplete' || ev.t === 'spawn') { + built[ev.defId] = (built[ev.defId] ?? 0) + 1; + } + } + } + } + const n = (id) => (built[id] ?? 0); + console.log(` ${games} games · towers ${n('lasertower')} · launchers ${n('missilelauncher')}` + + ` · airfields ${n('airfield')} · fighters ${n('fighter')} · bombers ${n('bomber')}`); + check('the AI builds Laser Towers', n('lasertower') > 0, 'never built one'); + check('the AI builds Missile Launchers', n('missilelauncher') > 0, 'never built one'); + check('the AI builds Airfields', n('airfield') > 0, 'never built one'); + check('the AI produces aircraft', n('fighter') + n('bomber') > 0, 'never built any'); + // Defence is a reaction, not a habit. An AI that answers every game with a wall of towers + // has stopped building an army, and the skill ladder above is measuring the wrong thing. + check('defence stays a minority of construction', + n('lasertower') + n('missilelauncher') < n('energygen') + n('massgen'), + `${n('lasertower') + n('missilelauncher')} defences vs ${n('energygen') + n('massgen')} generators`); + // Decisiveness is §11's job: it uses parameters tuned for it, whereas two equal skill-4 AIs + // on a mirrored map are supposed to be able to draw. +} + // --------------------------------------------------------------------------- section('13. Campaign winnability'); // ---------------------------------------------------------------------------