feat(totalannihilation): overhaul flight model to strafing behavior

Replace hover-based aircraft movement with a two-state system: LANDED
(grounded, cannot shoot, vulnerable to all weapons) and FLYING (at
cruise speed, uses air domain protection). Aircraft can no longer stop
in mid-air; armed units execute strafing passes with overshoot and
come-about logic.

Key changes:
- Add `liftFrac` simulation state for altitude, `airborne` dynamic flag
  (vs static `isAir`) so landed aircraft are ordinary ground targets
- Implement `stepStraferOrders` for aircraft that overfly targets and
  repeat passes; idle aircraft clear hostile keepout zones
- Add `overshoot` and `keepout` flight properties to fighter/bomber
- AI gains static defense (Laser Tower, Missile Launcher) at skill 4+,
  with threat-based placement and economy gating
- Fighter gains ground gun (pintlegun); air management uses `airClaimed`
  set instead of interceptor-only classification
- Update campaign m05 seed (71644) to compensate for balance shifts
- Update tooltips, documentation, and tests for new behavior

Closes #1234
This commit is contained in:
Brian Fertig 2026-08-01 13:15:58 -06:00
parent c56182bb10
commit f9e971b305
11 changed files with 975 additions and 301 deletions

View File

@ -598,7 +598,7 @@
"id": "m05", "id": "m05",
"name": "Tropic Vice", "name": "Tropic Vice",
"theme": "tropics", "theme": "tropics",
"seed": 55930, "seed": 71644,
"playerArmy": "arm", "playerArmy": "arm",
"playerCommander": "vance", "playerCommander": "vance",
"enemies": [ "enemies": [
@ -646,102 +646,102 @@
"h": 96, "h": 96,
"theme": "tropics", "theme": "tropics",
"rows": [ "rows": [
"~~~..........r.....~~~~~~~~~~~~~~~.rr.rr................rr.rr.~~~~~~~~~~~~~~~.....r..........~~~", "~~r....^^^^^^^^^^^^...........rr~~~...r...^^^^^^^^^^^^...r...~~~rr...........^^^^^^^^^^^^....r~~",
"~~.................~~~~~~~~~~~~~~~.rrrrr................rrrrr.~~~~~~~~~~~~~~~.................~~", "~~.....^^^^^^^^^^^^...........r.~~~........^^^....^^^........~~~.r...........^^^^^^^^^^^^.....~~",
"~~..................~~~~..r~~~~~~~~..........................~~~~~~~~r..~~~~..................~~", "~~....rr^^^^^^^^^^^............r~~~~.r....................r.~~~~r............^^^^^^^^^^^rr....~~",
"~.....................r........~~~~..........................~~~~........r.....................~", "~~~......^^^...................~~~~~~~rr................rr~~~~~~~...................^^^......~~~",
"...............................~~~~~........................~~~~~...............................", "~~~~.....rr....................~~~~~~~~~................~~~~~~~~~....................rr.....~~~~",
".......................r.......~~~~~........................~~~~~.......r.......................", "~~~~......rr.....................~~~~~~rr..............rr~~~~~~.....................rr......~~~~",
"......................rr.......~~~~~........................~~~~~.......rr......................", "~~~~......rr..............rr.....~~.....rr............rr.....~~.....rr..............rr......~~~~",
"......................rr.......~~~~~........................~~~~~.......rr......................", "~~~..................rrrrrrr.....~......rr............rr......~.....rrrrrrr..................~~~",
"....................rrrr.......~~~~~........................~~~~~.......rrrr....................", "~~~.................rrrrrrrr.....~r..........................r~.....rrrrrrrr.................~~~",
"...............rrr^^^^r........~~~~~........................~~~~~........r^^^^rrr...............", "~~~~................rrrr.............rr..................rr.............rrrr................~~~~",
"...............r^^^^^^^^.......~~~~~~r....................r~~~~~~.......^^^^^^^^r...............", "~~~~~...............rrrr.............rr..................rr.............rrrr...............~~~~~",
"..............rr^^^^^^^^r......~~~~~~~~~~~~~~~~..~~~~~~~~~~~~~~~~......r^^^^^^^^rr..............", "~~~~~~................rrr...........rr....................rr...........rrr................~~~~~~",
".............rrr^^^^^^^^.......~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.......^^^^^^^^rrr.............", "~~~~~~r...............rr................................................rr...............r~~~~~~",
".............rrr^^^^^^^........~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~........^^^^^^^rrr.............", "~~~~~.rr................................................................................rr.~~~~~",
"................^^^^............~~~~~~~~~~~~~~~rr~~~~~~~~~~~~~~~............^^^^................", "~~~...rr................................................................................rr...~~~",
".................r..............rr..~~~r...r........r...r~~~..rr..............r.................", "~~rr........................................................................................rr~~",
"...............................................rr...............................................", "~~rr....................rr..............rr............rr..............rr....................rr~~",
"r..r...........................................rr...........................................r..r", ".................rr....rrr..............rr............rr..............rrr....rr.................",
"..rrr..rr.^..........................................................................^.rr..rrr..", ".................rr..~~.....r....r...........rrrrrr...........r....r.....~~..rr.................",
"~~.r...rrrr^.................r....................................r.................^rrrr...r.~~", ".................r..~~~~...~~~~~~~~~.......^^^^..^^^^.......~~~~~~~~~...~~~~..r.................",
"~~~.rrr..rr^^................r....................................r................^^rr..rrr.~~~", "..................r.~~~~..~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~..~~~~.r..................",
"~~~..rr..r^^^^....................................................................^^^^r..rr..~~~", "..................r~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~r..................",
"~~~......^^^^^....................................................................^^^^^......~~~", ".................r.~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~.r.................",
"~~~.....^^^^^^^rr..............................................................rr^^^^^^^.....~~~", "................rrr~~~~~~~~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~~~~~~~~rrr................",
"........^^^^^^^^^..............................................................^^^^^^^^^........", "..............rrrr~~~~~~~.~~~~~~~~~~~...rr.^^^^..^^^^.rr...~~~~~~~~~~~.~~~~~~~rrrr..............",
"........^^^^^^^^^..............................................................^^^^^^^^^........", "..............rrr.~~~~~~...~~~~~~~~~~...rrr^^^^..^^^^rrr...~~~~~~~~~~...~~~~~~.rrr..............",
".......r^^^^r.^^^..............................................................^^^.r^^^^r.......", "..............r............................^^^^..^^^^............................r..............",
".......r^^^rr.r^^^............................................................^^^r.rr^^^r.......", "..............r...........................^^^^^^^^^^^^...........................r..............",
".......r^^rrrr..^^^^^^..r..............rr..............rr..............r..^^^^^^..rrrr^^r.......", "............rrrrrr.......................^^^^^^^^^^^^^^.......................rrrrrr............",
"........^...r...^^^^^^^^rr.............rrr.....rr.....rrr.............rr^^^^^^^^...r...^........", "........rr.rrrrrrr......................^^^^^^^^^^^^^^^^......................rrrrrrr.rr........",
"^^..............^^^^^^^^^r......~~~~...rrrrr^^.rr.^^rrrrr...~~~~......r^^^^^^^^^..............^^", "........rrrrr..rrr....................^^^^^^^^^^^^^^^^^^^^....................rrr..rrrrr........",
"^^^.............^^^^^^^^^......~~~~~...rrr^^^^^^^^^^^^rrr...~~~~~......^^^^^^^^^.............^^^", "...........rr...rr...................^^^^^^^^^^^^^^^^^^^^^^...................rr...rr...........",
"^^^^^^....rrr....^^^^^^^^^....~~~~~~.rr..^^^^^^^^^^^^^^..rr.~~~~~~....^^^^^^^^^....rrr....^^^^^^", "...........rr....r...........^......^^^^^^^^^^^^^^^^^^^^^^^^......^...........r....rr...........",
"^^^^^^r..rrrr....^^^^^^^^^...r~~~~~~rr...^^^^^^^^^^^^^^...rr~~~~~~r...^^^^^^^^^....rrrr..r^^^^^^", "...........................r^^^...r^^^^^^^^^^^^^^^^^^^^^^^^^^r...^^^r...........................",
"^^^^^^...rrrr.....^^^^^^^.....~~~~~~r....^^^^^^^^^^^^^^....r~~~~~~.....^^^^^^^.....rrrr...^^^^^^", "r...........................^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^....^^^...........................r",
"^^^^r..rr..r.....rr^^^^^.......~~~~~.....^^^^^^^^^^^^^^.....~~~~~.......^^^^^rr.....r..rr..r^^^^", "...................................r^^^^^^^^^^^^^^^^^^^^^^^^r...................................",
"^^^r..rrr.......rrr^^^^^........~~~.......^^^^^^^^^^^^.......~~~........^^^^^rrr.......rrr..r^^^", "......................................^^^^^^^^^^^^^^^^^^^^......................................",
"^^..rrrr.........r.^^^^r.........~........^^^^^^^^^^^^........~.........r^^^^.r.........rrrr..^^", ".................................rrr.....^^^^^^..^^^^^^.....rrr.................................",
"^r.rrrrr........rr.^^^^..........rrr.......^^^^^^^^^^.......rrr..........^^^^.rr........rrrrr.r^", ".........MM.....................rrrrr.....^^^^....^^^^.....rrrrr.....................MM.........",
"..rrrrrr.......rrr..^^^..........rr........r........r........rr..........^^^..rrr.......rrrrrr..", ".........MM.....^^...............rrrrr....................rrrrr...............^^.....MM.........",
"....rrrr......rrrr..^^...........rr........rr......rr........rr...........^^..rrrr......rrrr....", "................^^...............rrrrr....................rrrrr...............^^................",
"...............rrr...............rr........rr......rr........rr...............rrr...............", "...............^^^...............rrrrr....................rrrrr...............^^^...............",
"................................................................................................", "................^^^...............r..........................r...............^^^................",
"................................................................................................", "~~...............^^..........................................................^^...............~~",
"................................................................................................", "~~~.MM...........^^..........................................................^^...........MM.~~~",
"................................................................................................", "~~~.MM.......MM...^...rr................................................rr...^...MM.......MM.~~~",
".............................rrr................................rrr.............................", "~~...........MM.......rr................................................rr.......MM...........~~",
"...................MM........rrr................................rrr........MM...................", "~.....................rr................................................rr.....................~",
"...................MM.........rrr..............................rrr.........MM...................", "r..............................................................................................r",
"................................r..............................r................................", "....MM............rr........................................................rr............MM....",
"................................................................................................", "....MM............rr........................................................rr............MM....",
"................................................................................................", "..................rr~~~~................................................~~~~rr..................",
".....MM.................~~................~~~~~~~~~~~~................~~.................MM.....", "..................r.~~~~~~~..r..............^^....^^..............r..~~~~~~~.r..................",
".....MM................~~~~.............~~~~~~~~~~~~~~~~.............~~~~................MM.....", "...................~~~~~~~~rr..............^^^....^^^..............rr~~~~~~~~...................",
".......................~~~~.............~~~~~~~~~~~~~~~~.............~~~~.......................", "...................~~~~~~~~~...............^^^^..^^^^...............~~~~~~~~~...................",
"......................r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......................", "rr.................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~.................rr",
"........MM....MM......r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......MM....MM........", "rrr................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~................rrr",
"........MM....MM......r~~~~............r~~~~~~~~~~~~~~~~r............~~~~r......MM....MM........", "..r................~~~~~~~~~................^^....^^................~~~~~~~~~................r..",
"r........rrr............~~~...............~~~~~~~~~~~~...............~~~............rrr........r", "...................~~~~~~~~..........................................~~~~~~~~...................",
".........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..........................................................r....~~~~~~........", ".............r..........rrrrr....r....rr................rr....r....rrrrr..........r.............",
"^....rr.~~~~~~~...rr^^^..................................................^^^rr...~~~~~~~.rr....^", "...........rrrrr.........rr..........rr..................rr..........rr.........rrrrr...........",
"^^...rr.~~~~~~~...rr^^^...................^^^^^^^^^^^^...................^^^rr...~~~~~~~.rr...^^", "..........~~~.rr.........r...........rr.....~~....~~.....rr...........r.........rr.~~~..........",
"^^......~~~~~~~...rr^^^^.................^^^^^^^^^^^^^^.................^^^^rr...~~~~~~~......^^", "........r~~~~~rr.........rr..........rr....~~~~~~~~~~....rr..........rr.........rr~~~~~r........",
"^^......~~~~~~~r....^^^^.................^^^^^^^^^^^^^^.................^^^^....r~~~~~~~......^^", "........r~~~~~rrr.......rrr..........rrr...~~~~~~~~~~...rrr..........rrr.......rrr~~~~~r........",
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^", ".........~~~~~..rr......rrr...........rr...r~~~rr~~~r...rr...........rrr......rr..~~~~~.........",
"^^r.....~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~.....r^^", ".........~~~~~..rr......rrr..........................................rrr......rr..~~~~~.........",
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^", "........~~~~~~..rr............................................................rr..~~~~~~........",
"^^.....~~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~~.....^^", "........~~~~~~..rr............................................................rr..~~~~~~........",
"^^....r~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~r....^^", "........~~~~~~..rr............................................................rr..~~~~~~........",
"^.r..rr~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~rr..r.^", "........~~~~~~~.......................rrrr............rrrr.......................~~~~~~~........",
"^.rrr..~~~~~~~~.....^^^.............rr....^^^^^^^^^^^^....rr.............^^^.....~~~~~~~~..rrr.^", ".......~~~~~~~~......r..........r^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^r..........r......~~~~~~~~.......",
"..rrr..~~~~~~~~....r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r....~~~~~~~~..rrr..", ".......~~~~~~~~.....rrrr........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^........rrrr.....~~~~~~~~.......",
"..rrr..~~~~~~~~.....^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^.....~~~~~~~~..rrr..", ".......~~~~~~~~....rr.rr.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......rr.rr....~~~~~~~~.......",
"..rrr..~~~~~~~~~...r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r...~~~~~~~~~..rrr..", ".......~~~~~~~~.....~~.r.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......r.~~.....~~~~~~~~.......",
"..rrr..~~~~~~~~~....^^^..............rrrrr^^^^^^^^^^^^rrrrr..............^^^....~~~~~~~~~..rrr..", ".......~~~~~~~~.....~~..r......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......r..~~.....~~~~~~~~.......",
"..rrr..~~~~~~~~.....^^^...............rrrr^^^^^^^^^^^^rrrr...............^^^.....~~~~~~~~..rrr..", ".......~~~~~~~~.....~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~.....~~~~~~~~.......",
"..rrr..~~~~~~~~.....^^^...............rrr.^^^^^^^^^^^^.rrr...............^^^.....~~~~~~~~..rrr..", "......r~~~~~~~~.....~~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~~.....~~~~~~~~r......",
"^.rrr...~~~~~~~.....^^^................r..^^^^^^^^^^^^..r................^^^.....~~~~~~~...rrr.^", "......rr~~~~~~~.....~~~..rrr.....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....rrr..~~~.....~~~~~~~rr......",
"^^rrr....~~~~~......^^....................^^^^^^^^^^^^....................^^......~~~~~....rrr^^", "......rr~~~~~~~.....~~~~............^^^^^^^^^^^^^^^^^^^^^^^^............~~~~.....~~~~~~~rr......",
"^^r.......................................^^^^^^^^^^^^.......................................r^^", "......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.....^^^^^^^^^^^^^.......................^^^^^", "^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
"^^^^^.......................^^^^^^^^^^^^^....rrrrrr....^^^^^^^^^^^^^.......................^^^^^", "^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
"^^^^.........................^^^^^^^^^^......rrrrrr......^^^^^^^^^^.........................^^^^", "^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
"^^r...............................r..........rrrrrr..........r...............................r^^", "^^^................~~~~~~~~..........................................~~~~~~~~................^^^",
".r.....................r................................................r.....................r.", "^^^................~~~~~~~~...........r....r........r....r...........~~~~~~~~................^^^",
".............rr.......rr~~............................................~~rr.......rr.............", "^^^.................~~~~~~...........rrr..rrr......rrr..rrr...........~~~~~~.................^^^",
"r........rrrrrrr.......~~~~.......r..........................r.......~~~~.......rrrrrrr........r" "^^.....r......rr.r..~~~~~~............r....r........r....r............~~~~~~..r.rr......r.....^^"
], ],
"starts": [ "starts": [
{ {

View File

@ -14,9 +14,11 @@
"an optional second sprite that aims independently of the hull.", "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,", "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", "never collide with ground units, and while AIRBORNE can only be hit by weapons listing \"air\"",
"a roster with air units MUST keep some ground weapon anti-air capable or there is no answer to", "in `targets` \u2014 so a roster with air units MUST keep some ground weapon anti-air capable or",
"them. `domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged", "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", "`\"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." "own `blocksMove` \u2014 that is how `hover` crosses water without a per-terrain exception."
], ],
@ -33,6 +35,7 @@
"pathBudgetPerTick": 8, "pathBudgetPerTick": 8,
"retargetPeriodTicks": 10, "retargetPeriodTicks": 10,
"engageHoldFraction": 0.45, "engageHoldFraction": 0.45,
"siegeHoldFraction": 0.95,
"separationStiffness": 0.55, "separationStiffness": 0.55,
"shoveAfterSec": 0.6, "shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0, "stuckGiveUpSec": 3.0,
@ -901,12 +904,15 @@
"airfield" "airfield"
], ],
"weapons": [ "weapons": [
"aacannon" "aacannon",
"pintlegun"
], ],
"flight": { "flight": {
"height": 26, "height": 26,
"takeoffSec": 0.45, "takeoffSec": 0.45,
"landSec": 0.7 "landSec": 0.7,
"overshoot": 420,
"keepout": 700
}, },
"sheetSlot": "unitSheet", "sheetSlot": "unitSheet",
"frame": 12, "frame": 12,
@ -942,7 +948,9 @@
"flight": { "flight": {
"height": 30, "height": 30,
"takeoffSec": 0.7, "takeoffSec": 0.7,
"landSec": 1.0 "landSec": 1.0,
"overshoot": 540,
"keepout": 700
}, },
"sheetSlot": "unitSheet", "sheetSlot": "unitSheet",
"frame": 13, "frame": 13,
@ -1131,7 +1139,7 @@
"w": 1, "w": 1,
"h": 1 "h": 1
}, },
"hp": 3300, "hp": 1700,
"armorClass": "structure", "armorClass": "structure",
"sight": 300, "sight": 300,
"cost": { "cost": {
@ -1162,7 +1170,7 @@
"w": 2, "w": 2,
"h": 2 "h": 2
}, },
"hp": 4700, "hp": 3100,
"armorClass": "structure", "armorClass": "structure",
"sight": 420, "sight": 420,
"cost": { "cost": {

View File

@ -10,7 +10,7 @@
// Difficulty is decision quality, reaction time and an orders-per-second cap — not // 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. // 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 { canEngage } from './TARules.js';
import { worldToTileX, worldToTileY } from './TANav.js'; import { worldToTileX, worldToTileY } from './TANav.js';
@ -36,7 +36,7 @@ function memFor(state, armyIdx) {
nextThinkTick: 0, nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0, ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build', focusId: 0, squad: [], squadPeak: 0, phase: 'build', focusId: 0,
scoutId: 0, airPreyId: 0, scoutId: 0, airPreyId: 0, lastThreatTick: -99999,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}] knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999, lastAttackTick: -99999,
buildSpiral: 0, buildSpiral: 0,
@ -122,11 +122,30 @@ function observe(ctx) {
const byId = new Map(mem.knownEnemies.map((k) => [k.id, k])); const byId = new Map(mem.knownEnemies.map((k) => [k.id, k]));
for (const s of seen) byId.set(s.id, s); 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. // 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 cutoff = state.tick - ctx.rules.constants.tickHz * 120;
const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id)); const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id));
mem.knownEnemies = [...byId.values()] 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); .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) { function gatherOwn(ctx) {
@ -176,7 +195,7 @@ function manageEconomyAndBase(ctx, mine) {
const want = chooseBuilding(ctx, mine); const want = chooseBuilding(ctx, mine);
if (!want) return; if (!want) return;
const spot = findPlacement(ctx, mine, want, builder); const spot = findPlacement(ctx, mine, want, builder, placementOptionsFor(ctx, want));
if (!spot) return; if (!spot) return;
order(ctx, { order(ctx, {
@ -203,6 +222,12 @@ function chooseBuilding(ctx, mine) {
if (n('massgen') < 2) return mGen; if (n('massgen') < 2) return mGen;
if (n('vehicleplant') < 1) return plant; 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 // 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 // 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. // perfectly respectable game, which is why difficulty barely registered.
@ -216,6 +241,26 @@ function chooseBuilding(ctx, mine) {
return null; 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. // 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 // 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. // 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); const supported = Math.floor(army.mIncome / groundDrain);
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory(); 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 // Steady-state defence, once production capacity is where the economy can feed it.
// are already standing. Air is a SECOND front, not a substitute for the first: gated any const defence = steadyDefence(ctx, mine);
// earlier, a skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its if (defence) return defence;
// 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;
}
if (ratio > TARGET_RATIO && roomM) return mGen; if (ratio > TARGET_RATIO && roomM) return mGen;
if (ratio < TARGET_RATIO && roomE) return eGen; if (ratio < TARGET_RATIO && roomE) return eGen;
@ -262,6 +301,83 @@ function chooseBuilding(ctx, mine) {
return null; 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. */ /** Mass per second a factory of this type burns when kept busy. */
function factoryMassDrain(rules, def) { function factoryMassDrain(rules, def) {
const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean); 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 * 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. * 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 { state, rules } = ctx;
const mem = ctx.mem; const mem = ctx.mem;
const ts = state.tileSize;
const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x); const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x);
const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y); 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; if (spot) return spot;
} }
const maxR = 22; const minR = opts.minR ?? 2;
for (let r = 2; r < maxR; r++) { const maxR = opts.maxR ?? 22;
// Rotate the starting angle per attempt so the base doesn't grow as a solid slab — // `facing` narrows the spiral to an arc, so a Laser Tower goes on the side the attacks come
// AoE chains through tightly packed buildings. // from instead of wherever the spiral happened to have room. Tried twice: once constrained,
const steps = Math.max(8, r * 4); // then unconstrained, because a base hemmed in on that side must still get its tower up.
const off = (ctx.mem.buildSpiral++ % steps); const facing = opts.facing ?? null;
for (let s = 0; s < steps; s++) { for (const arc of facing ? [opts.arc ?? Math.PI / 3, Math.PI] : [Math.PI]) {
const a = ((s + off) / steps) * Math.PI * 2; for (let r = minR; r < maxR; r++) {
const tx = bx + Math.round(Math.cos(a) * r); // Rotate the starting angle per attempt so the base doesn't grow as a solid slab —
const ty = by + Math.round(Math.sin(a) * r); // AoE chains through tightly packed buildings.
if (!spacedEnough(ctx, mine, tx, ty, def)) continue; const steps = Math.max(8, r * 4);
if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty }; 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; 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) { function findMetalSpot(ctx, def, bx, by) {
const { state, rules } = ctx; const { state, rules } = ctx;
let best = null, bestD = Infinity; let best = null, bestD = Infinity;
@ -482,22 +622,27 @@ function sweepTarget(ctx) {
// Air superiority // Air superiority
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** A unit whose guns can't touch the ground at all — an interceptor, not an attacker. */ /** Can this def shoot at aircraft at all? */
function isInterceptor(def) { function canHitAir(def) {
const ws = def.weaponDefs ?? []; return (def.weaponDefs ?? []).some((w) => w.targetsAir);
return ws.length > 0 && !ws.some((w) => w.targetsGround);
} }
/** /**
* Interceptors get their own job. Handing them to manageMilitary would attach them to the * Interception. Aircraft that can shoot other aircraft are pulled out of the ground push ONLY
* ground push, where they would fly into the enemy base, be unable to shoot a single thing in * while there is enemy air to answer the Fighter carries a ground gun as well now, so leaving
* it, and die to the first missile tower so they hunt enemy aircraft instead, and hold over * it permanently on air patrol would idle half its usefulness whenever the enemy owns no
* the base when there are none. * planes. Anything not claimed here falls through to manageMilitary and joins the attack.
*/ */
function manageAir(ctx, mine) { function manageAir(ctx, mine) {
const { state, rules, mem, armyIdx } = ctx; const { state, rules, mem, armyIdx } = ctx;
const fighters = mine.units.filter((u) => isInterceptor(rules.defById[u.defId])); if (!mem.airClaimed) mem.airClaimed = new Set();
if (!fighters.length) return; 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; let prey = null, preyD = Infinity;
for (const e of state.entities) { 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); const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
if (d < preyD) { prey = e; preyD = d; } if (d < preyD) { prey = e; preyD = d; }
} }
if (!prey) { mem.airPreyId = 0; return; }
if (prey) { 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, // Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue, so
// so re-picking a bandit every think would restart the intercept before it ever closed. // re-picking a bandit every think would restart the intercept before it ever closed. Only
// Only re-issue when the current one is gone or someone has fallen out of the flight. // 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); const committed = fighters.every((u) => u.orders[0]?.type === 'attack' && u.orders[0].targetId === mem.airPreyId);
if (committed && mem.airPreyId === prey.id) return; if (committed && mem.airPreyId === prey.id) return;
mem.airPreyId = prey.id; mem.airPreyId = prey.id;
// Send the whole flight at one bandit — aircraft trade far too fast to arrive piecemeal. // Send the whole flight at one bandit — aircraft trade far too fast to arrive piecemeal.
order(ctx, { order(ctx, {
army: armyIdx, unitIds: fighters.map((u) => u.id), army: armyIdx, unitIds: fighters.map((u) => u.id),
order: { type: 'attack', targetId: prey.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 },
});
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -545,7 +679,7 @@ function manageMilitary(ctx, mine) {
if (u.id === mem.scoutId) return false; if (u.id === mem.scoutId) return false;
const def = rules.defById[u.defId]; const def = rules.defById[u.defId];
if (!def.weaponDefs?.length || def.builds?.length) return false; 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; return u.orders.length === 0;
}); });

View File

@ -207,7 +207,10 @@ export default class TAHud {
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`); 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 // 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. // 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'); else if (def.moveClass === 'hover') lines.push('HOVER — crosses water');
for (const w of def.weaponDefs ?? []) { for (const w of def.weaponDefs ?? []) {
const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only'; const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only';

View File

@ -143,9 +143,24 @@ function baseEntity(state, army, def) {
army, army,
defId: def.id, defId: def.id,
isBuilding: !!def.isBuilding, isBuilding: !!def.isBuilding,
// Cached off the def because the movement, separation, targeting and projectile loops all // Two related flags, and the difference matters.
// branch on it every tick — this is the hottest flag in the sim. //
// `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, 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, x: 0, y: 0, px: 0, py: 0,
heading: 0, pheading: 0, heading: 0, pheading: 0,
turretRot: 0, pturretRot: 0, turretRot: 0, pturretRot: 0,
@ -759,12 +774,197 @@ function findAutoHealTarget(state, rules, e, def) {
return best; 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) { function stepOrders(state, rules) {
const ts = state.tileSize; const ts = state.tileSize;
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e.site) continue; if (e.dead || e.site) continue;
if (e.isBuilding) continue; if (e.isBuilding) continue;
const def = defOf(rules, e); 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]; const order = e.orders[0];
if (!order) { if (!order) {
@ -835,7 +1035,7 @@ function stepOrders(state, rules) {
if (order.type === 'attackMove' && def.maxRange > 0) { if (order.type === 'attackMove' && def.maxRange > 0) {
const foe = entityById(state, e.targetId); const foe = entityById(state, e.targetId);
if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army] 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.path = null;
e.movingTo = null; e.movingTo = null;
e.stuckTicks = 0; 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; } if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
e.targetId = target.id; e.targetId = target.id;
const d = surfaceDist(rules, e, target); 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 (d > wantRange) {
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y); if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: 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; 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 dx = tx - e.x, dy = ty - e.y;
const dist = Math.hypot(dx, dy); const dist = Math.hypot(dx, dy);
@ -985,13 +1202,26 @@ function stepMovement(state, rules) {
terrainMul = tcost ? 1 / tcost : 1; terrainMul = tcost ? 1 / tcost : 1;
} }
// Slow down while still swinging round, and ease into the final waypoint. let speed, step;
const misalign = Math.abs(angleDelta(e.heading, want)); if (def.strafes) {
const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1); // A strafing aircraft holds cruise speed no matter what it is doing: the ONLY thing that
const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1; // 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
const speed = def.speed * terrainMul * turnMul * arriveMul; // into a landing, and it is never allowed to creep or hang.
const step = Math.min(speed * dt, dist); 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.vx = Math.cos(e.heading) * step / dt;
e.vy = Math.sin(e.heading) * step / dt; e.vy = Math.sin(e.heading) * step / dt;
e.x += Math.cos(e.heading) * step; e.x += Math.cos(e.heading) * step;
@ -1168,11 +1398,20 @@ function stepCombat(state, rules) {
const def = defOf(rules, e); const def = defOf(rules, e);
if (!def.weaponDefs?.length) continue; 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]--; 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 // 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. // 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); let target = entityById(state, e.targetId);
if (target && !inWeaponRange(rules, e, def, target)) target = null; if (target && !inWeaponRange(rules, e, def, target)) target = null;
const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0; const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0;
@ -1206,7 +1445,7 @@ function stepCombat(state, rules) {
if (e.reload[wi] > 0) continue; if (e.reload[wi] > 0) continue;
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked. // 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 (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; const dist = surface;
if (dist > w.range || dist < (w.minRange ?? 0)) continue; if (dist > w.range || dist < (w.minRange ?? 0)) continue;
if (!aligned) continue; if (!aligned) continue;
@ -1226,7 +1465,7 @@ function stepCombat(state, rules) {
for (let wi = 0; wi < def.weaponDefs.length; wi++) { for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi]; const w = def.weaponDefs[wi];
if (w.manual) continue; 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) { if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
fireWeapon(state, rules, e, w, target, facing, aimPt); fireWeapon(state, rules, e, w, target, facing, aimPt);
e.burstLeft[wi]--; e.burstLeft[wi]--;
@ -1285,7 +1524,7 @@ function acquireTarget(state, rules, e, def, hash, near) {
for (const w of def.weaponDefs) { for (const w of def.weaponDefs) {
// A weapon that can't reach the target's domain contributes nothing, so a unit with no // 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. // 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; if (d > w.range || d < (w.minRange ?? 0)) continue;
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1; const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
const s = (w.damage * mul) / (1 + d / w.range); const s = (w.damage * mul) / (1 + d / w.range);
@ -1413,7 +1652,7 @@ function stepProjectiles(state, rules) {
let bestT = Infinity, hit = null; let bestT = Infinity, hit = null;
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e.site || e.army === p.army) continue; 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; 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); const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
if (s < bestT) { bestT = s; hit = e; } if (s < bestT) { bestT = s; hit = e; }
@ -1430,7 +1669,7 @@ function stepProjectiles(state, rules) {
else { else {
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e.army === p.army) continue; 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) { 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 }); applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
break; break;
@ -1467,7 +1706,7 @@ function applyAoe(state, rules, x, y, w, source) {
if (e.dead) continue; if (e.dead) continue;
if (e.army === source.army && !rules.constants.friendlyFire) continue; if (e.army === source.army && !rules.constants.friendlyFire) continue;
// Splash respects the domain too — a bomb's blast radius stays on the ground. // 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 // 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 // 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 // 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 }); state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius });
for (const o of state.entities) { for (const o of state.entities) {
if (o.dead || o.id === e.id) continue; 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; const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius;
if (d > ex.radius) continue; if (d > ex.radius) continue;
const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7; 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); stepEconomy(state, rules);
stepOrders(state, rules); stepOrders(state, rules);
stepFlight(state, rules);
servicePathQueue(state, rules); servicePathQueue(state, rules);
stepMovement(state, rules); stepMovement(state, rules);
stepSeparation(state, rules); stepSeparation(state, rules);
@ -1743,6 +1983,10 @@ export function serialize(state) {
lastDamagedTick: e.lastDamagedTick, lastDamagedTick: e.lastDamagedTick,
reload: e.reload, burstLeft: e.burstLeft, reload: e.reload, burstLeft: e.burstLeft,
terrainBonus: e.terrainBonus, 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. // 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); const e = baseEntity(state, s.army, def);
Object.assign(e, s); Object.assign(e, s);
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot; 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.radius = def.isBuilding ? def.radius : (def.radius ?? 16);
e.path = null; e.pathIdx = 0; e.wantPath = false; e.path = null; e.pathIdx = 0; e.wantPath = false;
state.entities.push(e); state.entities.push(e);

View File

@ -251,6 +251,10 @@ export function compileRules(json) {
// its x/y say it is for movement, collision, targeting and clicking. It is declared // 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 // 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. // 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) { if (u.flight) {
for (const k of ['height', 'takeoffSec']) { for (const k of ['height', 'takeoffSec']) {
if (!(u.flight[k] > 0)) fail(`unit "${u.id}" flight.${k} must be positive`); 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`); fail(`unit "${u.id}" flight.landSec must be positive when present`);
} }
u.flight.landSec = u.flight.landSec ?? u.flight.takeoffSec; 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) { } else if (u.isAir) {
fail(`air unit "${u.id}" needs a flight block, or it will render sitting on the ground`); 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 * 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 * 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. * 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) { 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; for (const w of def.weaponDefs ?? []) if (weaponHitsDomain(w, air)) return true;
return false; return false;
} }

View File

@ -33,10 +33,10 @@ export const DEPTHS = {
// Altitude, for units whose def carries a `flight` block (everything the Airfield builds). // 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 // The height itself is simulation state (`e.liftFrac`) because it gates whether a unit may
// x/y say it is for movement, collision, targeting and clicking, and only the SPRITE is // fire; the renderer only turns that number into pixels. The entity's x/y stay on the ground
// lifted. That is what keeps this free — no extra sim work, no state to serialize, and a unit // for movement, collision, targeting and clicking — only the SPRITE is lifted — so a unit is
// you can still click where you see its shadow. // 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 // 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 // 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 PIE_JOB_COLOR = 0x8ad4ff; // build blue, same language as sites and nanolathe
const TAU = Math.PI * 2; const TAU = Math.PI * 2;
/** Move `cur` toward `want` by at most `step`. */ /** Smoothstep, so a takeoff eases out of the ground and into the climb instead of snapping. */
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. */
function smoothstep(t) { return t * t * (3 - 2 * t); } function smoothstep(t) { return t * t * (3 - 2 * t); }
export default class TAWorldView { 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} 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 * @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
*/ */
render(alpha, deltaMs = 16.7) { render(alpha) {
const { state, rules } = this; 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(); this._updateChunks();
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; } 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 y = e.py + (e.y - e.py) * alpha;
const heading = lerpAngle(e.pheading, e.heading, alpha); const heading = lerpAngle(e.pheading, e.heading, alpha);
// Altitude. The trigger is deliberately "has been given something to do" rather than // Altitude comes from the SIM — it decides whether a unit may fire, so the renderer must
// "is physically moving": e.orders fills the instant the order is issued, a tick before // not run a second copy of it that could disagree. Interpolated between ticks exactly
// stepMovement has translated the unit anywhere, so the climb visibly leads the move // like position, then eased so a takeoff rolls off the ground instead of snapping.
// instead of trailing it. A unit standing on a target it is shooting stays up too.
let lift = 0; let lift = 0;
if (def.flight) { if (def.flight) {
const airborne = !!(e.movingTo || e.orders.length || e.targetId); s.alt = e.pliftFrac + (e.liftFrac - e.pliftFrac) * alpha;
const secs = airborne ? def.flight.takeoffSec : def.flight.landSec;
s.alt = approach(s.alt, airborne ? 1 : 0, dtSec / secs);
lift = def.flight.height * smoothstep(s.alt); lift = def.flight.height * smoothstep(s.alt);
} }

View File

@ -241,9 +241,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
for (const ev of events) this._onSimEvent(ev); for (const ev of events) this._onSimEvent(ev);
// Real frame delta, NOT delta * simSpeed — takeoff is a view animation and should look the const { nanoLinks, projectiles } = this.view.render(st.alpha);
// same whether the match is paused, at 1x or at 4x.
const { nanoLinks, projectiles } = this.view.render(st.alpha, delta);
this.fx.draw(delta, nanoLinks, projectiles, time); this.fx.draw(delta, nanoLinks, projectiles, time);
this._syncSelection(); this._syncSelection();
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null); this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);

View File

@ -81,10 +81,20 @@ movement, collision, targeting and clicking:
| Hover Constructor | 9px | 0.35s | 0.5s | | 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 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, something to do, the **sprite** rises by `height` while a black, 50%-opacity copy of its own
50%-opacity copy of its own frame pinned to the true ground position — the growing gap between frame stays pinned to the true ground position — the growing gap between the two is the
the two is the altitude. It settles back down when idle. `height` is the one number to tune if altitude. It settles back down when idle. `height` is the one number to tune if a unit reads as
a unit reads as too glued down or too detached. 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: Three consequences for the art:

View File

@ -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 // 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 // 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 = [ const MISSIONS = [
{ {
id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', 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.', 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 }, enemy: { commander: 'cybro', skill: 4, aggression: 0.7 },
startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }], startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }],
prebuilt: [ prebuilt: [

View File

@ -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 // Height is SIMULATION state (`e.liftFrac`) because it decides whether a unit may fire. This
// the REAL TAWorldView against the stub scene and reading the sprites back. The sim is // section only checks that the renderer turns that number into the right pixels — the flight
// never ticked here — orders are set and frames are rendered — which is itself the point: // behaviour itself is §6g, where it can be tested headlessly like everything else.
// if any of this leaked into TALogic these fixtures could not work at all.
check('the Hover Constructor hovers without being an air unit', check('the Hover Constructor hovers without being an air unit',
!!rules.unitById.hoverconstructor.flight && rules.unitById.hoverconstructor.isAir === false, !!rules.unitById.hoverconstructor.flight && rules.unitById.hoverconstructor.isAir === false,
'the visual and the simulation domain must stay separate'); '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 map = generateMap(rules, { seed: 4242, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
const FRAME = 1000 / 60; const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
const scene = makeStubScene();
/** Fresh view + a single grounded fighter, with fog off so visibility never masks a bug. */ try {
const rig = () => {
const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
const scene = makeStubScene();
const view = new TAWorldView(scene, rules, artJson, st, 0); const view = new TAWorldView(scene, rules, artJson, st, 0);
view.setFogEnabled(false); view.setFogEnabled(false);
const def = rules.unitById.fighter;
const start = st.starts.find((x) => x.army === 0); 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 e = L.spawnUnit(st, rules, 0, 'fighter', start.x * st.tileSize, start.y * st.tileSize);
const run = (sec, frameMs = FRAME) => { const run = (sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, rules); };
for (let t = 0; t < sec * 1000; t += frameMs) view.render(0, frameMs);
};
return { st, view, e, run, spr: () => view.sprites.get(e.id) };
};
let r = rig(); run(0.2);
try { view.render(0);
const def = rules.unitById.fighter; let s = view.sprites.get(e.id);
r.run(1.0); check('a grounded aircraft renders on the ground', Math.abs(s.img.y - e.y) < 0.01,
let s = r.spr(); `body ${(s.img.y - e.y).toFixed(2)}px off the ground`);
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`);
check('a grounded aircraft casts no visible shadow', s.shadow.alpha < 0.001, check('a grounded aircraft casts no visible shadow', s.shadow.alpha < 0.001,
`alpha ${s.shadow.alpha.toFixed(3)}`); `alpha ${s.shadow.alpha.toFixed(3)}`);
// Giving it somewhere to be is what starts the climb — orders fill a tick before the sim L.issueOrder(st, rules, { army: 0, unitIds: [e.id], order: { type: 'move', x: e.x + 2000, y: e.y } });
// has moved it anywhere, so the takeoff leads the movement rather than trailing it. run(2.0);
L.issueOrder(r.st, rules, { // Rendered at alpha 1 — an airborne unit is in motion now, so anything comparing sprite
army: 0, unitIds: [r.e.id], order: { type: 'move', x: r.e.x + 600, y: r.e.y }, // 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.
r.run(0.1); view.render(1);
s = r.spr(); s = view.sprites.get(e.id);
check('the climb starts before the unit has travelled', s.img.y < r.e.y - 0.5, const lift = e.y - s.img.y;
`only ${(r.e.y - s.img.y).toFixed(2)}px up after 0.1s`); check('a flying aircraft is drawn at its full hover height',
r.run(2.0);
s = r.spr();
const lift = r.e.y - s.img.y;
check('a moving aircraft reaches its full hover height',
Math.abs(lift - def.flight.height) < 0.5, `lifted ${lift.toFixed(1)} of ${def.flight.height}px`); 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', 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', check('the shadow separates from the body by the hover height',
Math.abs((s.shadow.y - s.img.y) - def.flight.height) < 0.5); 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, check('the airborne shadow is 50% transparent', Math.abs(s.shadow.alpha - 0.5) < 0.001,
`alpha ${s.shadow.alpha.toFixed(3)}`); `alpha ${s.shadow.alpha.toFixed(3)}`);
check('the shadow tightens as the unit climbs', s.shadow.scaleX < s.img.scaleX); 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 // Lifting the sprite must not re-sort it against its neighbours, or an aircraft would pop
// pop in front of things as it took off. // in front of things as it took off.
const depthUp = s.img.depth;
check('depth is taken from the ground position, not the lifted sprite', 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. // Altitude is interpolated between ticks exactly like position, so a climb is smooth at
r.e.orders.length = 0; r.e.movingTo = null; r.e.targetId = 0; // any framerate rather than stepping 20 times a second. Position is frozen here so the
r.run(2.0); // only thing differing between the two renders is the altitude.
s = r.spr(); e.px = e.x; e.py = e.y;
check('an aircraft settles back onto the ground when idle', e.pliftFrac = 0; e.liftFrac = 1;
Math.abs(s.img.y - r.e.y) < 0.01 && s.shadow.alpha < 0.001, view.render(0);
`body ${(s.img.y - r.e.y).toFixed(2)}px up, shadow alpha ${s.shadow.alpha.toFixed(3)}`); 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) { } catch (err) {
check('the takeoff animation runs', false, err.message); check('the altitude renderer 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();
} }
} }
@ -1432,34 +1399,46 @@ section('6f. Air domain and hover');
// ---- ground weapons cannot touch aircraft, and air-only weapons cannot touch the ground ---- // ---- 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 st = arena(6);
const inf = []; const inf = [];
for (let i = 0; i < 8; i++) inf.push(L.spawnUnit(st, ar, 0, 'infantry', px(8), px(14) + i * 30)); 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: 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); runFor(st, 60);
check('rifles cannot damage a Fighter', fighter.hp === fighter.maxHp, check('rifles cannot damage a Fighter', fighter.hp === fighter.maxHp,
`${fighter.hp.toFixed(0)}/${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 st = arena(7);
const troopers = []; const troopers = [];
for (let i = 0; i < 8; i++) troopers.push(L.spawnUnit(st, ar, 0, 'rockettrooper', px(8), px(14) + i * 30)); 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)); 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); runFor(st, 60);
check('shoulder rockets do reach a Fighter', fighter.dead || fighter.hp < fighter.maxHp, check('shoulder rockets do reach a Fighter', fighter.dead || fighter.hp < fighter.maxHp,
`${fighter.hp.toFixed(0)}/${fighter.maxHp}`); `${fighter.hp.toFixed(0)}/${fighter.maxHp}`);
} }
{ {
// Fighters are the answer to fighters; that is the whole point of an air-only weapon. // 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 st = arena(8);
const a = L.spawnUnit(st, ar, 0, 'fighter', px(10), px(20)); 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); 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)}`); `${a.hp.toFixed(0)} v ${b.hp.toFixed(0)}`);
} }
@ -1468,12 +1447,17 @@ section('6f. Air domain and hover');
const st = arena(9); const st = arena(9);
const bomber = L.spawnUnit(st, ar, 0, 'bomber', px(8), px(20)); const bomber = L.spawnUnit(st, ar, 0, 'bomber', px(8), px(20));
const tank = L.spawnUnit(st, ar, 1, 'tank', px(13), 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)); 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: 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); runFor(st, 60);
check('a Bomber damages ground armour', tank.dead || tank.hp < tank.maxHp, check('a Bomber damages ground armour', tank.dead || tank.hp < tank.maxHp,
`${tank.hp.toFixed(0)}/${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, check('bomb splash does not reach the aircraft above it', overhead.hp === overhead.maxHp,
`${overhead.hp.toFixed(0)}/${overhead.maxHp}`); `${overhead.hp.toFixed(0)}/${overhead.maxHp}`);
} }
@ -1483,6 +1467,10 @@ section('6f. Air domain and hover');
const st = arena(10); const st = arena(10);
const inf = L.spawnUnit(st, ar, 0, 'infantry', px(10), px(20)); const inf = L.spawnUnit(st, ar, 0, 'infantry', px(10), px(20));
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(12), 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.issueOrder(st, ar, { army: 0, unitIds: [inf.id], order: { type: 'attack', targetId: fighter.id } });
L.tick(st, ar); L.tick(st, ar);
check('an attack order on an unreachable domain is dropped', inf.orders.length === 0, 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'); 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'); section('13. Campaign winnability');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------