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

View File

@ -14,9 +14,11 @@
"an optional second sprite that aims independently of the hull.",
"",
"Domains: a unit's `domain` is \"ground\" (the default) or \"air\". Air units ignore the nav grid,",
"never collide with ground units, and can only be hit by weapons listing \"air\" in `targets` \u2014 so",
"a roster with air units MUST keep some ground weapon anti-air capable or there is no answer to",
"them. `domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged",
"never collide with ground units, and while AIRBORNE can only be hit by weapons listing \"air\"",
"in `targets` \u2014 so a roster with air units MUST keep some ground weapon anti-air capable or",
"there is no answer to them. That protection comes from flying, not from having wings: a landed",
"aircraft is an ordinary ground target that anything can shoot, and it cannot fire back.",
"`domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged",
"`\"air\": true`. Terrain passability comes from the move class COST TABLE, not from the terrain's",
"own `blocksMove` \u2014 that is how `hover` crosses water without a per-terrain exception."
],
@ -33,6 +35,7 @@
"pathBudgetPerTick": 8,
"retargetPeriodTicks": 10,
"engageHoldFraction": 0.45,
"siegeHoldFraction": 0.95,
"separationStiffness": 0.55,
"shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0,
@ -901,12 +904,15 @@
"airfield"
],
"weapons": [
"aacannon"
"aacannon",
"pintlegun"
],
"flight": {
"height": 26,
"takeoffSec": 0.45,
"landSec": 0.7
"landSec": 0.7,
"overshoot": 420,
"keepout": 700
},
"sheetSlot": "unitSheet",
"frame": 12,
@ -942,7 +948,9 @@
"flight": {
"height": 30,
"takeoffSec": 0.7,
"landSec": 1.0
"landSec": 1.0,
"overshoot": 540,
"keepout": 700
},
"sheetSlot": "unitSheet",
"frame": 13,
@ -1131,7 +1139,7 @@
"w": 1,
"h": 1
},
"hp": 3300,
"hp": 1700,
"armorClass": "structure",
"sight": 300,
"cost": {
@ -1162,7 +1170,7 @@
"w": 2,
"h": 2
},
"hp": 4700,
"hp": 3100,
"armorClass": "structure",
"sight": 420,
"cost": {

View File

@ -10,7 +10,7 @@
// Difficulty is decision quality, reaction time and an orders-per-second cap — not
// income cheating. A cheating AI's win rate would tell us nothing about the balance.
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt } from './TALogic.js';
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt, angleDelta } from './TALogic.js';
import { canEngage } from './TARules.js';
import { worldToTileX, worldToTileY } from './TANav.js';
@ -36,7 +36,7 @@ function memFor(state, armyIdx) {
nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
scoutId: 0, airPreyId: 0,
scoutId: 0, airPreyId: 0, lastThreatTick: -99999,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999,
buildSpiral: 0,
@ -122,11 +122,30 @@ function observe(ctx) {
const byId = new Map(mem.knownEnemies.map((k) => [k.id, k]));
for (const s of seen) byId.set(s.id, s);
// Forget anything we haven't seen in two minutes, and anything we can see is gone.
//
// BUILDINGS are exempt from the timer: they do not move, so a remembered position stays
// correct until the thing itself dies, and `live` already covers that. Ageing them out was
// making the AI lose track of a base it had already found, fall back to blind sweeping, and
// fail to finish games it had overwhelmingly won — one skirmish ended the clock with 30
// units against three.
const cutoff = state.tick - ctx.rules.constants.tickHz * 120;
const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id));
mem.knownEnemies = [...byId.values()]
.filter((k) => k.tick >= cutoff && live.has(k.id))
.filter((k) => (k.isBuilding || k.tick >= cutoff) && live.has(k.id))
.sort((a, b) => a.id - b.id);
// When did something hostile last get inside the base perimeter? Static defence is a
// REACTION in this AI — built because the base is being raided, not on a timer — so this
// one number is what the whole defensive build-out hangs off.
if (mem.baseSet) {
const r = state.tileSize * 16;
for (const s of seen) {
if (s.isBuilding) continue;
if (Math.hypot(s.x - mem.baseX, s.y - mem.baseY) > r) continue;
mem.lastThreatTick = state.tick;
break;
}
}
}
function gatherOwn(ctx) {
@ -176,7 +195,7 @@ function manageEconomyAndBase(ctx, mine) {
const want = chooseBuilding(ctx, mine);
if (!want) return;
const spot = findPlacement(ctx, mine, want, builder);
const spot = findPlacement(ctx, mine, want, builder, placementOptionsFor(ctx, want));
if (!spot) return;
order(ctx, {
@ -203,6 +222,12 @@ function chooseBuilding(ctx, mine) {
if (n('massgen') < 2) return mGen;
if (n('vehicleplant') < 1) return plant;
// Something is in the base and there is nothing static shooting back at it. This one jumps
// the whole economic queue: a raid that is happening now is not answered by another
// generator, and a base with no anti-air at all is farmed indefinitely by a single bomber.
const urgent = urgentDefence(ctx, mine);
if (urgent) return urgent;
// Skills 1-2 stop here: a short fixed ladder and no governor at all. Without this the
// bottom of the ladder still runs the same ratio-aware economy as the top and plays a
// perfectly respectable game, which is why difficulty barely registered.
@ -216,6 +241,26 @@ function chooseBuilding(ctx, mine) {
return null;
}
// Exactly one Airfield, once there is a real economy and the ground factories are already
// standing. Air is a SECOND front, not a substitute for the first: gated any earlier, a
// skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its barracks
// were being overrun, and lost campaign missions it had been winning. Skills 1-2 never reach
// this branch, so air stays a mark of a competent opponent.
//
// Gated on STRUCTURE COUNTS, and placed ahead of the ratio governor below on purpose. Two
// earlier attempts keyed on mass income never fired in practice: income peaks between 11 and
// 27 across skirmishes, and while a high-expansion AI is spending hard it is almost always
// stalling slightly, so the generator rules below would win every single time. A skill-5 AI
// built an Airfield in zero games out of six before this moved.
// Measured across 24 skirmishes: generators peak at 4-10 energy and 6-10 mass, so those two
// counts are reachable in nearly every game, while `buildEff > 0.7` held for as little as
// 20% of ticks in the games where the AI was spending hardest. 0.5 still refuses to start an
// 1800-energy structure in a real brownout without gating air behind a placid economy.
if (airfield && n('airfield') < 1 && n('barracks') + n('vehicleplant') >= 2
&& n('energygen') >= 4 && n('massgen') >= 3 && army.buildEff > 0.5) {
return airfield;
}
// Steady state: chase the energy:mass income RATIO that the unit roster actually costs.
// A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources
// symmetrically ends up capping out on energy while its factories starve for mass.
@ -244,15 +289,9 @@ function chooseBuilding(ctx, mine) {
const supported = Math.floor(army.mIncome / groundDrain);
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory();
// Exactly one Airfield, and only once the ground factories the economy can actually feed
// are already standing. Air is a SECOND front, not a substitute for the first: gated any
// earlier, a skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its
// barracks were being overrun, and lost campaign missions it had been winning. Skills 1-2
// never reach this branch at all, so air stays a mark of a competent opponent.
if (airfield && n('airfield') < 1 && n('barracks') + n('vehicleplant') >= 2
&& army.mIncome >= groundDrain * 2.5 && army.buildEff > 0.95) {
return airfield;
}
// Steady-state defence, once production capacity is where the economy can feed it.
const defence = steadyDefence(ctx, mine);
if (defence) return defence;
if (ratio > TARGET_RATIO && roomM) return mGen;
if (ratio < TARGET_RATIO && roomE) return eGen;
@ -262,6 +301,83 @@ function chooseBuilding(ctx, mine) {
return null;
}
/**
* Static defence is a skill-4-and-up behaviour, like the Airfield is skill 3 and up.
*
* These structures hit hard for their cost a Laser Tower puts out 130 dps behind 1700 hp for
* 60% of a Tank's price and the ground roster has no dedicated siege answer to them yet, so
* two equally-matched AIs both digging in grind rather than resolve. Reserving them for the
* stronger opponents puts that pressure where a player goes looking for it and leaves the low
* end of the ladder playing the open game it was tuned for. Drop this to 3 (or 1) once there is
* a unit whose job is cracking fortifications.
*/
const DEFENCE_MIN_SKILL = 4;
/** Has anything hostile been inside the base perimeter recently? */
function underThreat(ctx) {
return ctx.state.tick - (ctx.mem.lastThreatTick ?? -99999) < ctx.rules.constants.tickHz * 90;
}
/** Have we actually SEEN enemy aircraft? Fog-limited, like every other AI decision. */
function enemyAirSeen(ctx) {
return ctx.mem.knownEnemies.some((k) => ctx.rules.defById[k.defId]?.isAir);
}
/**
* The one structure the base needs RIGHT NOW, ahead of any economic consideration. Kept
* deliberately tiny one tower against a raid, one launcher against aircraft. Everything
* beyond the first of each is a steady-state decision (see steadyDefence), because an AI that
* answers pressure by building walls stops building an army and loses slowly instead of fast.
*/
function urgentDefence(ctx, mine) {
const { rules } = ctx;
if (ctx.skill < DEFENCE_MIN_SKILL) return null;
const n = (id) => (mine.byDef[id] ?? 0);
const aa = rules.buildingById.missilelauncher;
const tower = rules.buildingById.lasertower;
// Anti-air first: a Laser Tower cannot shoot at aircraft at all, so a base being bombed gets
// nothing whatsoever from one.
if (aa && enemyAirSeen(ctx) && n('missilelauncher') < 1) return aa;
if (tower && underThreat(ctx) && n('lasertower') + n('missilelauncher') < 1) return tower;
return null;
}
/**
* Defence build-out from surplus. Capped hard and only ever in response to something actually
* observed this AI wins by attacking, and a turtle that spends its income on towers fields
* no army to attack with.
*/
function steadyDefence(ctx, mine) {
const { rules, expansion } = ctx;
if (ctx.skill < DEFENCE_MIN_SKILL) return null;
const n = (id) => (mine.byDef[id] ?? 0);
const aa = rules.buildingById.missilelauncher;
const tower = rules.buildingById.lasertower;
// More launchers the more air we've seen, because that is the threat they answer.
if (aa && enemyAirSeen(ctx)) {
const airSeen = ctx.mem.knownEnemies.filter((k) => rules.defById[k.defId]?.isAir).length;
if (n('missilelauncher') < Math.min(3, 1 + Math.floor(airSeen / 2))) return aa;
}
// Two reasons to fortify: the base is being raided, or the army has marched off and left it
// uncovered. The second is the one a human plays instinctively — a committed push is exactly
// when a base is most exposed — but it buys only a token, because an AI that answers its own
// aggression with a wall of towers stops fielding the army that was winning it the game.
// Purely reactive: only a base that is actually being raided fortifies. An earlier version
// also fortified whenever the army marched out, which reads as sensible play and measured
// terribly — static defence is strong enough here that both sides digging in turned 7 of 8
// skirmishes into unresolved slugfests.
if (!underThreat(ctx)) return null;
// Tower, then launcher, then the rest of the towers. The launcher comes second rather than
// last because it out-ranges the tower and is the only static piece that can shoot upward at
// all — a position built purely from lasers has no answer whatsoever the first time it is
// overflown, and waiting for the full tower count to finish meant it almost never got built.
if (tower && n('lasertower') < 1) return tower;
if (aa && n('missilelauncher') < 1) return aa;
if (tower && n('lasertower') < Math.round(1 + expansion * 2)) return tower;
return null;
}
/** Mass per second a factory of this type burns when kept busy. */
function factoryMassDrain(rules, def) {
const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean);
@ -274,10 +390,9 @@ function factoryMassDrain(rules, def) {
* Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for
* a metal patch, which is what makes map control matter without a reclaim economy.
*/
function findPlacement(ctx, mine, def, builder) {
function findPlacement(ctx, mine, def, builder, opts = {}) {
const { state, rules } = ctx;
const mem = ctx.mem;
const ts = state.tileSize;
const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x);
const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y);
@ -286,23 +401,48 @@ function findPlacement(ctx, mine, def, builder) {
if (spot) return spot;
}
const maxR = 22;
for (let r = 2; r < maxR; r++) {
const minR = opts.minR ?? 2;
const maxR = opts.maxR ?? 22;
// `facing` narrows the spiral to an arc, so a Laser Tower goes on the side the attacks come
// from instead of wherever the spiral happened to have room. Tried twice: once constrained,
// then unconstrained, because a base hemmed in on that side must still get its tower up.
const facing = opts.facing ?? null;
for (const arc of facing ? [opts.arc ?? Math.PI / 3, Math.PI] : [Math.PI]) {
for (let r = minR; r < maxR; r++) {
// Rotate the starting angle per attempt so the base doesn't grow as a solid slab —
// AoE chains through tightly packed buildings.
const steps = Math.max(8, r * 4);
const off = (ctx.mem.buildSpiral++ % steps);
for (let s = 0; s < steps; s++) {
const a = ((s + off) / steps) * Math.PI * 2;
if (facing != null && Math.abs(angleDelta(a, facing)) > arc) continue;
const tx = bx + Math.round(Math.cos(a) * r);
const ty = by + Math.round(Math.sin(a) * r);
if (!spacedEnough(ctx, mine, tx, ty, def)) continue;
if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty };
}
}
}
return null;
}
/**
* Where a given structure wants to sit. Defences are the only things with an opinion: a Laser
* Tower is a perimeter piece and belongs on the approach the enemy actually uses, while a
* Missile Launcher is protecting the base itself from something that can arrive over any edge
* of it, so it goes in among the buildings it is covering.
*/
function placementOptionsFor(ctx, def) {
if (def.id === 'lasertower') {
const t = enemyBaseGuess(ctx);
const facing = t && ctx.mem.baseSet
? Math.atan2(t.y - ctx.mem.baseY, t.x - ctx.mem.baseX) : null;
return { minR: 5, maxR: 16, facing, arc: Math.PI / 3 };
}
if (def.id === 'missilelauncher') return { minR: 2, maxR: 10 };
return {};
}
function findMetalSpot(ctx, def, bx, by) {
const { state, rules } = ctx;
let best = null, bestD = Infinity;
@ -482,22 +622,27 @@ function sweepTarget(ctx) {
// Air superiority
// ---------------------------------------------------------------------------
/** A unit whose guns can't touch the ground at all — an interceptor, not an attacker. */
function isInterceptor(def) {
const ws = def.weaponDefs ?? [];
return ws.length > 0 && !ws.some((w) => w.targetsGround);
/** Can this def shoot at aircraft at all? */
function canHitAir(def) {
return (def.weaponDefs ?? []).some((w) => w.targetsAir);
}
/**
* Interceptors get their own job. Handing them to manageMilitary would attach them to the
* ground push, where they would fly into the enemy base, be unable to shoot a single thing in
* it, and die to the first missile tower so they hunt enemy aircraft instead, and hold over
* the base when there are none.
* Interception. Aircraft that can shoot other aircraft are pulled out of the ground push ONLY
* while there is enemy air to answer the Fighter carries a ground gun as well now, so leaving
* it permanently on air patrol would idle half its usefulness whenever the enemy owns no
* planes. Anything not claimed here falls through to manageMilitary and joins the attack.
*/
function manageAir(ctx, mine) {
const { state, rules, mem, armyIdx } = ctx;
const fighters = mine.units.filter((u) => isInterceptor(rules.defById[u.defId]));
if (!fighters.length) return;
if (!mem.airClaimed) mem.airClaimed = new Set();
mem.airClaimed.clear();
const fighters = mine.units.filter((u) => {
const def = rules.defById[u.defId];
return def.isAir && canHitAir(def);
});
if (!fighters.length) { mem.airPreyId = 0; return; }
let prey = null, preyD = Infinity;
for (const e of state.entities) {
@ -506,11 +651,12 @@ function manageAir(ctx, mine) {
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
if (d < preyD) { prey = e; preyD = d; }
}
if (!prey) { mem.airPreyId = 0; return; }
if (prey) {
// Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue,
// so re-picking a bandit every think would restart the intercept before it ever closed.
// Only re-issue when the current one is gone or someone has fallen out of the flight.
for (const u of fighters) mem.airClaimed.add(u.id);
// Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue, so
// re-picking a bandit every think would restart the intercept before it ever closed. Only
// re-issue when the current one is gone or someone has fallen out of the flight.
const committed = fighters.every((u) => u.orders[0]?.type === 'attack' && u.orders[0].targetId === mem.airPreyId);
if (committed && mem.airPreyId === prey.id) return;
mem.airPreyId = prey.id;
@ -519,18 +665,6 @@ function manageAir(ctx, mine) {
army: armyIdx, unitIds: fighters.map((u) => u.id),
order: { type: 'attack', targetId: prey.id },
});
return;
}
mem.airPreyId = 0;
// Nothing to intercept: park the idle ones over the base so they're already in position.
const loitering = fighters.filter((u) => !u.orders.length
&& Math.hypot(u.x - mem.baseX, u.y - mem.baseY) > state.tileSize * 6);
if (loitering.length) {
order(ctx, {
army: armyIdx, unitIds: loitering.map((u) => u.id),
order: { type: 'move', x: mem.baseX, y: mem.baseY },
});
}
}
// ---------------------------------------------------------------------------
@ -545,7 +679,7 @@ function manageMilitary(ctx, mine) {
if (u.id === mem.scoutId) return false;
const def = rules.defById[u.defId];
if (!def.weaponDefs?.length || def.builds?.length) return false;
if (isInterceptor(def)) return false; // manageAir owns these
if (mem.airClaimed?.has(u.id)) return false; // manageAir has these on intercept
return u.orders.length === 0;
});

View File

@ -207,7 +207,10 @@ export default class TAHud {
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`);
// Which layer a unit lives in decides whether the player's current army can even shoot
// it, so it belongs in the tooltip above the individual weapon lines.
if (def.isAir) lines.push('AIRCRAFT — only anti-air weapons can hit it');
if (def.isAir) {
lines.push('AIRCRAFT — only anti-air can hit it while flying');
lines.push('Landed it cannot shoot, and anything can hit it');
}
else if (def.moveClass === 'hover') lines.push('HOVER — crosses water');
for (const w of def.weaponDefs ?? []) {
const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only';

View File

@ -143,9 +143,24 @@ function baseEntity(state, army, def) {
army,
defId: def.id,
isBuilding: !!def.isBuilding,
// Cached off the def because the movement, separation, targeting and projectile loops all
// branch on it every tick — this is the hottest flag in the sim.
// Two related flags, and the difference matters.
//
// `isAir` is STATIC: which layer the unit moves through. It never changes, and it is what
// movement, separation and the nav grid read — a parked aircraft still doesn't pathfind.
//
// `airborne` is DYNAMIC: whether it is currently off the ground. This is what WEAPONS
// read, so an aircraft sitting on its pad is an ordinary ground target that a rifle or a
// tank shell can hit. Being untouchable is a property of flying, not of having wings.
isAir: !!def.isAir,
airborne: false,
// Altitude, 0 = on the ground, 1 = fully airborne. This lives in the SIM rather than the
// renderer because it is a rule, not a decoration: a grounded aircraft cannot fire, and
// its speed ramps with its height. `pliftFrac` is the previous tick's value, for the same
// reason px/py exist — the view interpolates between the two.
liftFrac: 0, pliftFrac: 0,
// Strafing run bookkeeping: once a pass goes overhead the unit commits to a point beyond
// the target and flies to it before coming about.
egressing: false, egressX: 0, egressY: 0,
x: 0, y: 0, px: 0, py: 0,
heading: 0, pheading: 0,
turretRot: 0, pturretRot: 0,
@ -759,12 +774,197 @@ function findAutoHealTarget(state, rules, e, def) {
return best;
}
const MOVE_LIKE = new Set(['move', 'attackMove', 'patrol']);
/**
* How far into its own weapon envelope a unit closes before stopping to shoot a building.
*
* A building that SHOOTS BACK gets the near-maximum fraction, so anything out-ranging it can
* stand off and dismantle it for free which is the entire counterplay to a defensive
* structure, and the reason a Rocket Tank (440) exists alongside a Tank (350) against a Laser
* Tower (340). Everything else keeps the low fraction that was tuned for base assaults:
* measured at 64 games, closing all the way in ran the skill ladder at 90% and holding at
* range dropped it to 68%, so this stays narrowly scoped to targets that can hurt you.
*/
function holdFractionFor(rules, target) {
const c = rules.constants;
const def = rules.defById[target.defId];
return def?.weaponDefs?.length ? (c.siegeHoldFraction ?? 0.95) : (c.engageHoldFraction ?? 0.85);
}
/** A point `dist` ahead of where `e` is pointing. */
function forwardPoint(e, dist) {
return { x: e.x + Math.cos(e.heading) * dist, y: e.y + Math.sin(e.heading) * dist };
}
/** Nearest live hostile building within `radius` of (x,y), or null. */
function hostileBuildingNear(state, rules, army, x, y, radius) {
let best = null, bestD = radius;
for (const t of state.entities) {
if (t.dead || !t.isBuilding || t.army === army || !state.armies[t.army]) continue;
const d = surfaceDist(rules, { x, y }, t);
if (d < bestD) { best = t; bestD = d; }
}
return best;
}
/**
* Where a strafing aircraft should point right now, given the thing it is attacking.
*
* The run has two legs. INBOUND steers straight at the target and holds until the aircraft is
* genuinely overhead; EGRESS carries on to a point `overshoot` past it, which is what gives
* the pass its follow-through and the room to come about. The arc of the turn is not scripted
* it falls out of the unit's turnRate against a speed that never drops.
*/
function strafePoint(state, rules, e, def, t) {
const reach = Math.max(64, e.radius * 2);
if (e.egressing) {
if (Math.hypot(e.egressX - e.x, e.egressY - e.y) > reach) return { x: e.egressX, y: e.egressY };
e.egressing = false;
}
// Two ways to know the pass is spent: overhead, or the target has slipped behind us. The
// second matters because a Fighter covers 15px per tick and can cross a small overhead
// radius between two samples without ever testing inside it.
const bearing = Math.atan2(t.y - e.y, t.x - e.x);
const behind = Math.abs(angleDelta(e.heading, bearing)) > 2.0;
const dist = Math.hypot(t.x - e.x, t.y - e.y);
const overhead = surfaceDist(rules, e, t) <= Math.max(40, e.radius);
if (e.liftFrac >= 1 && (overhead || (behind && dist < def.flight.overshoot))) {
e.egressing = true;
const p = forwardPoint(e, def.flight.overshoot);
// Clamped inside the world, or a run started near the edge parks the aircraft against the
// map boundary waiting to reach a point it can never get to.
e.egressX = Math.max(e.radius, Math.min(state.worldW - e.radius, p.x));
e.egressY = Math.max(e.radius, Math.min(state.worldH - e.radius, p.y));
return { x: e.egressX, y: e.egressY };
}
return { x: t.x, y: t.y };
}
/**
* Orders for an aircraft that cannot stop in the air.
*
* The whole shape of this is: it is either LANDED (still, and unable to shoot) or FLYING (at
* cruise, always going somewhere). There is no third option, so every order resolves to a
* steering point plus a decision about whether to be off the ground at all.
*/
function stepStraferOrders(state, rules, e, def) {
const keepout = def.flight.keepout;
e.buildTargetId = 0;
// A move into a hostile base is not a move, it is an attack run. Landing there would put
// the aircraft stationary and defenceless under the guns of whatever it flew to.
const first = e.orders[0];
if (first && MOVE_LIKE.has(first.type)) {
const dest = orderDestination(e, first);
const foe = hostileBuildingNear(state, rules, e.army, dest.x, dest.y, keepout);
if (foe) e.orders[0] = { type: 'attack', targetId: foe.id };
}
const order = e.orders[0];
if (!order) {
// Idle: put down where it stands, unless that is inside somebody's base — then clear out
// first and land once there is room.
const foe = hostileBuildingNear(state, rules, e.army, e.x, e.y, keepout);
if (foe) {
e._wantAirborne = true;
const dx = e.x - foe.x, dy = e.y - foe.y;
const d = Math.hypot(dx, dy) || 1;
e.movingTo = { x: e.x + (dx / d) * keepout, y: e.y + (dy / d) * keepout };
} else {
e._wantAirborne = false;
e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null;
}
return;
}
switch (order.type) {
case 'stop':
e.orders.length = 0; e.targetId = 0; e.egressing = false; e.movingTo = null;
break;
case 'hold':
// For something that cannot hover, holding a position means being on the ground at it.
e._wantAirborne = false;
e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null;
break;
case 'attack':
case 'guard': {
const t = entityById(state, order.targetId);
if (!t || (order.type === 'attack' && !canEngage(def, t))) {
e.orders.shift(); e.egressing = false; e.targetId = 0;
break;
}
e._wantAirborne = true;
e.movingTo = strafePoint(state, rules, e, def, t);
break;
}
case 'move':
case 'attackMove':
case 'patrol': {
const dest = orderDestination(e, order);
const d = Math.hypot(dest.x - e.x, dest.y - e.y);
const arrive = Math.max(rules.constants.arriveSlackPx, e.radius * 2);
e.movingTo = dest;
if (d > arrive) { e._wantAirborne = true; break; }
if (order.type === 'patrol') {
order.leg = order.leg === 0 ? 1 : 0;
e._wantAirborne = true;
e.movingTo = orderDestination(e, order);
break;
}
// Arrived. Settle onto the spot — speed ramps down with altitude in stepMovement, so
// this reads as an approach rather than a stop, and the order only clears once down.
e._wantAirborne = false;
if (e.liftFrac <= 0) {
e.orders.shift(); e.movingTo = null; e.egressing = false;
}
break;
}
default:
e.orders.shift();
}
}
/**
* Advance every flight unit's altitude toward where its orders want it. Runs after stepOrders
* (which sets `_wantAirborne`) and before stepMovement (which scales speed by the result).
*/
function stepFlight(state, rules) {
const dt = rules.dt;
for (const e of state.entities) {
if (e.dead || e.isBuilding || e.site) continue;
const def = defOf(rules, e);
if (!def.flight) continue;
// Units that merely hover are up whenever they have something to do — they are allowed to
// stop in mid-air, so their altitude is decoration rather than a rule.
if (!def.strafes) e._wantAirborne = !!(e.orders.length || e.movingTo || e.targetId);
e.pliftFrac = e.liftFrac;
const want = e._wantAirborne ? 1 : 0;
const step = dt / (want ? def.flight.takeoffSec : def.flight.landSec);
// Note the explicit "already there" case. Folding it into the descend branch makes a unit
// holding altitude drop a step and climb it back every other tick, which flickers both the
// sprite and — because cruise speed is scaled by this — the unit's actual velocity.
if (want > e.liftFrac) e.liftFrac = Math.min(want, e.liftFrac + step);
else if (want < e.liftFrac) e.liftFrac = Math.max(want, e.liftFrac - step);
// The instant the wheels leave the ground it becomes an air target, and the instant they
// touch down it stops being one.
e.airborne = e.isAir && e.liftFrac > 0;
}
}
function stepOrders(state, rules) {
const ts = state.tileSize;
for (const e of state.entities) {
if (e.dead || e.site) continue;
if (e.isBuilding) continue;
const def = defOf(rules, e);
// An armed aircraft plays by a different rulebook — it has no stationary state in the air,
// so none of the arrive/hold/close-to-range logic below applies to it.
if (def.strafes) { stepStraferOrders(state, rules, e, def); continue; }
const order = e.orders[0];
if (!order) {
@ -835,7 +1035,7 @@ function stepOrders(state, rules) {
if (order.type === 'attackMove' && def.maxRange > 0) {
const foe = entityById(state, e.targetId);
if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army]
&& surfaceDist(rules, e, foe) <= def.maxRange * (rules.constants.engageHoldFraction ?? 0.85)) {
&& surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, foe)) {
e.path = null;
e.movingTo = null;
e.stuckTicks = 0;
@ -870,7 +1070,9 @@ function stepOrders(state, rules) {
if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
e.targetId = target.id;
const d = surfaceDist(rules, e, target);
const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85);
const wantRange = def.maxRange * (target.isBuilding
? holdFractionFor(rules, target)
: (rules.constants.engageHoldFraction ?? 0.85));
if (d > wantRange) {
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
@ -967,7 +1169,22 @@ function stepMovement(state, rules) {
tx = e.movingTo.x; ty = e.movingTo.y;
}
if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; }
if (tx == null) {
// A stationary turretless unit can otherwise fire only within the arc it happens to be
// facing, because nothing ever turns it. That made infantry anti-air close to
// decorative: a strafing aircraft crosses that window in well under a second, and eight
// rocket troopers got off four shots in a minute.
//
// Restricted to AIR targets on purpose. Allowing it against ground targets as well is
// arguably more correct, but measured over the campaign it strengthens massed infantry
// on defence enough to flip m06 from winnable to not — a ground-balance change has no
// business riding along inside an anti-air fix.
if (!e.isBuilding && e.targetId && def.turretFrame == null && def.maxRange > 0) {
const t = entityById(state, e.targetId);
if (t && t.airborne) e.heading = turnToward(e.heading, Math.atan2(t.y - e.y, t.x - e.x), def.turnRate * dt);
}
e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue;
}
const dx = tx - e.x, dy = ty - e.y;
const dist = Math.hypot(dx, dy);
@ -985,13 +1202,26 @@ function stepMovement(state, rules) {
terrainMul = tcost ? 1 / tcost : 1;
}
let speed, step;
if (def.strafes) {
// A strafing aircraft holds cruise speed no matter what it is doing: the ONLY thing that
// changes in the air is its heading, which is what turns a course correction into an arc
// instead of a pirouette. Speed tracks altitude, so it accelerates off the pad and slows
// into a landing, and it is never allowed to creep or hang.
speed = def.speed * e.liftFrac;
// No clamp to the remaining distance while airborne — clamping is what would let it stop
// dead on top of its target instead of carrying through the pass. It does clamp on the
// way down, so a landing settles on its spot rather than skidding past it.
step = e.liftFrac >= 1 ? speed * dt : Math.min(speed * dt, dist);
} else {
// Slow down while still swinging round, and ease into the final waypoint.
const misalign = Math.abs(angleDelta(e.heading, want));
const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1);
const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1;
const speed = def.speed * terrainMul * turnMul * arriveMul;
const step = Math.min(speed * dt, dist);
speed = def.speed * terrainMul * turnMul * arriveMul;
step = Math.min(speed * dt, dist);
}
if (step <= 0) { e.vx = 0; e.vy = 0; continue; }
e.vx = Math.cos(e.heading) * step / dt;
e.vy = Math.sin(e.heading) * step / dt;
e.x += Math.cos(e.heading) * step;
@ -1168,11 +1398,20 @@ function stepCombat(state, rules) {
const def = defOf(rules, e);
if (!def.weaponDefs?.length) continue;
// Reloads run down on the pad too, so an aircraft rearms while it is grounded.
for (let wi = 0; wi < e.reload.length; wi++) if (e.reload[wi] > 0) e.reload[wi]--;
// A grounded aircraft cannot shoot. This is the flip side of not being able to hover:
// sitting still and being useful are mutually exclusive for anything with wings.
if (def.flight && e.liftFrac < 1) { e.targetId = 0; continue; }
// Staggered acquisition — the single biggest CPU lever in the sim. Re-scan only every
// retargetPeriodTicks, phase-shifted per unit so the cost spreads across ticks.
const holdOrder = e.orders[0]?.type === 'attack';
//
// A strafing aircraft is deliberately NOT pinned to its ordered target. Its orders decide
// where it flies; its guns take whatever is in range on the way past, which is the whole
// point of a unit that crosses the battlefield rather than parking in front of one thing.
const holdOrder = e.orders[0]?.type === 'attack' && !def.strafes;
let target = entityById(state, e.targetId);
if (target && !inWeaponRange(rules, e, def, target)) target = null;
const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0;
@ -1206,7 +1445,7 @@ function stepCombat(state, rules) {
if (e.reload[wi] > 0) continue;
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked.
if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
if (!weaponHitsDomain(w, target.isAir)) continue;
if (!weaponHitsDomain(w, target.airborne)) continue;
const dist = surface;
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
if (!aligned) continue;
@ -1226,7 +1465,7 @@ function stepCombat(state, rules) {
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi];
if (w.manual) continue;
if (!weaponHitsDomain(w, target.isAir)) continue;
if (!weaponHitsDomain(w, target.airborne)) continue;
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
fireWeapon(state, rules, e, w, target, facing, aimPt);
e.burstLeft[wi]--;
@ -1285,7 +1524,7 @@ function acquireTarget(state, rules, e, def, hash, near) {
for (const w of def.weaponDefs) {
// A weapon that can't reach the target's domain contributes nothing, so a unit with no
// AA at all never scores an aircraft and simply ignores it.
if (!weaponHitsDomain(w, t.isAir)) continue;
if (!weaponHitsDomain(w, t.airborne)) continue;
if (d > w.range || d < (w.minRange ?? 0)) continue;
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
const s = (w.damage * mul) / (1 + d / w.range);
@ -1413,7 +1652,7 @@ function stepProjectiles(state, rules) {
let bestT = Infinity, hit = null;
for (const e of state.entities) {
if (e.dead || e.site || e.army === p.army) continue;
if (!weaponHitsDomain(w, e.isAir)) continue;
if (!weaponHitsDomain(w, e.airborne)) continue;
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
if (s < bestT) { bestT = s; hit = e; }
@ -1430,7 +1669,7 @@ function stepProjectiles(state, rules) {
else {
for (const e of state.entities) {
if (e.dead || e.army === p.army) continue;
if (!weaponHitsDomain(w, e.isAir)) continue;
if (!weaponHitsDomain(w, e.airborne)) continue;
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) {
applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
break;
@ -1467,7 +1706,7 @@ function applyAoe(state, rules, x, y, w, source) {
if (e.dead) continue;
if (e.army === source.army && !rules.constants.friendlyFire) continue;
// Splash respects the domain too — a bomb's blast radius stays on the ground.
if (!weaponHitsDomain(w, e.isAir)) continue;
if (!weaponHitsDomain(w, e.airborne)) continue;
// Falloff is measured to the SURFACE, so a shell that lands squarely on a building's
// face counts as a direct hit wherever along that face it strikes. Measuring from the
// centre circle instead scored an off-axis wall hit as 32px away and cut its damage by
@ -1533,7 +1772,7 @@ function killEntity(state, rules, e, source) {
state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius });
for (const o of state.entities) {
if (o.dead || o.id === e.id) continue;
if (o.isAir) continue; // a ground blast doesn't reach whatever is flying over it
if (o.airborne) continue; // a ground blast doesn't reach whatever is flying over it
const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius;
if (d > ex.radius) continue;
const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7;
@ -1641,6 +1880,7 @@ export function tick(state, rules) {
stepEconomy(state, rules);
stepOrders(state, rules);
stepFlight(state, rules);
servicePathQueue(state, rules);
stepMovement(state, rules);
stepSeparation(state, rules);
@ -1743,6 +1983,10 @@ export function serialize(state) {
lastDamagedTick: e.lastDamagedTick,
reload: e.reload, burstLeft: e.burstLeft,
terrainBonus: e.terrainBonus,
// Altitude decides whether a unit may fire, so a save that dropped it would resume with
// every aircraft grounded and mute for a second.
liftFrac: Math.round(e.liftFrac * 1000) / 1000,
egressing: e.egressing, egressX: e.egressX, egressY: e.egressY,
})),
// Projectiles are sub-second transients; dropping them costs nothing and saves bytes.
});
@ -1790,6 +2034,10 @@ export function deserialize(rules, raw) {
const e = baseEntity(state, s.army, def);
Object.assign(e, s);
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
e.pliftFrac = e.liftFrac;
// Derived from the restored altitude, so a save resumes with aircraft in the same domain
// they were in rather than every one of them briefly shootable by anything.
e.airborne = e.isAir && e.liftFrac > 0;
e.radius = def.isBuilding ? def.radius : (def.radius ?? 16);
e.path = null; e.pathIdx = 0; e.wantPath = false;
state.entities.push(e);

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
// separately from `domain` on purpose: the Hover Constructor floats visibly but is a
// ground unit that rifles can hit and that has to path around cliffs.
// An armed aircraft STRAFES: it cannot stop in the air, so it makes firing passes and
// lands to sit still. An unarmed one (or a merely hovering ground unit like the Hover
// Constructor) keeps the simple stop-where-you-are behaviour.
u.strafes = u.isAir && (u.weapons ?? []).length > 0;
if (u.flight) {
for (const k of ['height', 'takeoffSec']) {
if (!(u.flight[k] > 0)) fail(`unit "${u.id}" flight.${k} must be positive`);
@ -259,6 +263,13 @@ export function compileRules(json) {
fail(`unit "${u.id}" flight.landSec must be positive when present`);
}
u.flight.landSec = u.flight.landSec ?? u.flight.takeoffSec;
if (u.strafes) {
// How far past the target it carries before turning about, and how close to a hostile
// structure it is willing to put down. Both are distances in pixels.
for (const k of ['overshoot', 'keepout']) {
if (!(u.flight[k] > 0)) fail(`strafing unit "${u.id}" needs a positive flight.${k}`);
}
}
} else if (u.isAir) {
fail(`air unit "${u.id}" needs a flight block, or it will render sitting on the ground`);
}
@ -410,9 +421,12 @@ export function weaponHitsDomain(weapon, targetIsAir) {
* Does `def` carry ANY weapon that can reach `target`'s domain? Anything that can't should
* never acquire, chase or fire at it a rifleman ordered to attack a Fighter would otherwise
* follow it across the map forever, never able to shoot.
*
* Reads `airborne`, not `isAir`: a landed aircraft is an ordinary ground target, so this flips
* for the same unit as it takes off and touches down.
*/
export function canEngage(def, target) {
const air = !!target.isAir;
const air = !!target.airborne;
for (const w of def.weaponDefs ?? []) if (weaponHitsDomain(w, air)) return true;
return false;
}

View File

@ -33,10 +33,10 @@ export const DEPTHS = {
// Altitude, for units whose def carries a `flight` block (everything the Airfield builds).
//
// Entirely view-side. The simulation has no height axis: the entity stays exactly where its
// x/y say it is for movement, collision, targeting and clicking, and only the SPRITE is
// lifted. That is what keeps this free — no extra sim work, no state to serialize, and a unit
// you can still click where you see its shadow.
// The height itself is simulation state (`e.liftFrac`) because it gates whether a unit may
// fire; the renderer only turns that number into pixels. The entity's x/y stay on the ground
// for movement, collision, targeting and clicking — only the SPRITE is lifted — so a unit is
// still exactly where you see its shadow.
//
// The shadow stays pinned to the true ground position while the body rises off it, so the gap
// between the two IS the altitude cue. At rest they are coincident and the shadow is fully
@ -53,13 +53,7 @@ const PIE_QUEUE_COLOR = 0xffd27a; // amber — the batch
const PIE_JOB_COLOR = 0x8ad4ff; // build blue, same language as sites and nanolathe
const TAU = Math.PI * 2;
/** Move `cur` toward `want` by at most `step`. */
function approach(cur, want, step) {
if (cur < want) return Math.min(want, cur + step);
return Math.max(want, cur - step);
}
/** Smoothstep, so a takeoff eases out of the ground and into the hover instead of snapping. */
/** Smoothstep, so a takeoff eases out of the ground and into the climb instead of snapping. */
function smoothstep(t) { return t * t * (3 - 2 * t); }
export default class TAWorldView {
@ -417,14 +411,10 @@ export default class TAWorldView {
/**
* @param {number} alpha interpolation factor between the last two sim ticks
* @param {number} [deltaMs] real frame time, for framerate-independent view animation
* @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
*/
render(alpha, deltaMs = 16.7) {
render(alpha) {
const { state, rules } = this;
// Clamped: a tab that was backgrounded for ten seconds must not teleport every aircraft
// to full altitude in one frame.
const dtSec = Math.min(0.1, Math.max(0, deltaMs) / 1000);
this._updateChunks();
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
@ -449,15 +439,12 @@ export default class TAWorldView {
const y = e.py + (e.y - e.py) * alpha;
const heading = lerpAngle(e.pheading, e.heading, alpha);
// Altitude. The trigger is deliberately "has been given something to do" rather than
// "is physically moving": e.orders fills the instant the order is issued, a tick before
// stepMovement has translated the unit anywhere, so the climb visibly leads the move
// instead of trailing it. A unit standing on a target it is shooting stays up too.
// Altitude comes from the SIM — it decides whether a unit may fire, so the renderer must
// not run a second copy of it that could disagree. Interpolated between ticks exactly
// like position, then eased so a takeoff rolls off the ground instead of snapping.
let lift = 0;
if (def.flight) {
const airborne = !!(e.movingTo || e.orders.length || e.targetId);
const secs = airborne ? def.flight.takeoffSec : def.flight.landSec;
s.alt = approach(s.alt, airborne ? 1 : 0, dtSec / secs);
s.alt = e.pliftFrac + (e.liftFrac - e.pliftFrac) * alpha;
lift = def.flight.height * smoothstep(s.alt);
}

View File

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

View File

@ -81,10 +81,20 @@ movement, collision, targeting and clicking:
| Hover Constructor | 9px | 0.35s | 0.5s |
At rest the unit sits flat on the ground with no visible shadow. The moment it is given
something to do, the renderer eases the **sprite** upward by `height` while leaving a black,
50%-opacity copy of its own frame pinned to the true ground position — the growing gap between
the two is the altitude. It settles back down when idle. `height` is the one number to tune if
a unit reads as too glued down or too detached.
something to do, the **sprite** rises by `height` while a black, 50%-opacity copy of its own
frame stays pinned to the true ground position — the growing gap between the two is the
altitude. It settles back down when idle. `height` is the one number to tune if a unit reads as
too glued down or too detached.
The height itself is **simulation state** (`e.liftFrac`), not a render animation, because it
decides whether a unit is allowed to fire. The renderer only interpolates it between ticks.
Armed aircraft additionally carry `overshoot` and `keepout` (both in pixels) and are flagged
`strafes` by the rules compiler. They cannot stop in the air: a move order ends in a landing,
an attack order becomes a repeating firing pass, and a landed one can neither shoot nor claim
the protection of the air domain — on the ground it is an ordinary target that any weapon can
hit. The Hover Constructor is exempt — it is a ground unit that merely floats, and it does not
attack.
Three consequences for the art:

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
// closing to point blank shifted attack/defence enough to turn m02's old seed from playable
// into unwinnable (0/8), with no change to the mission's own numbers.
// into unwinnable (0/8), with no change to the mission's own numbers. Giving armed aircraft a
// strafing flight model did the same to m05 later on. Both times the mission was fine and the
// battlefield was not.
const MISSIONS = [
{
id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x',
@ -70,7 +72,11 @@ const MISSIONS = [
defeat: 'KLAXON out-produced us and it showed.',
},
{
id: 'm05', name: 'Tropic Vice', seed: 55930, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
// Re-seeded twice as combat balance moved under it, both times measured over an 8-run
// sweep: 55930 -> 40411 when armed aircraft stopped being able to hover, then 40411 (which
// fell to 4/8) -> 71644 when the AI learned to build static defences. Cy-Bro is skill 4
// here, which is exactly the level at which defences switch on.
id: 'm05', name: 'Tropic Vice', seed: 71644, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
enemy: { commander: 'cybro', skill: 4, aggression: 0.7 },
startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }],
prebuilt: [

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
// the REAL TAWorldView against the stub scene and reading the sprites back. The sim is
// never ticked here — orders are set and frames are rendered — which is itself the point:
// if any of this leaked into TALogic these fixtures could not work at all.
// Height is SIMULATION state (`e.liftFrac`) because it decides whether a unit may fire. This
// section only checks that the renderer turns that number into the right pixels — the flight
// behaviour itself is §6g, where it can be tested headlessly like everything else.
check('the Hover Constructor hovers without being an air unit',
!!rules.unitById.hoverconstructor.flight && rules.unitById.hoverconstructor.isAir === false,
'the visual and the simulation domain must stay separate');
@ -329,92 +328,60 @@ section('2e. Takeoff, hover and shadow');
}
const map = generateMap(rules, { seed: 4242, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
const FRAME = 1000 / 60;
/** Fresh view + a single grounded fighter, with fog off so visibility never masks a bug. */
const rig = () => {
const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
const scene = makeStubScene();
try {
const view = new TAWorldView(scene, rules, artJson, st, 0);
view.setFogEnabled(false);
const def = rules.unitById.fighter;
const start = st.starts.find((x) => x.army === 0);
const e = L.spawnUnit(st, rules, 0, 'fighter', start.x * st.tileSize, start.y * st.tileSize);
const run = (sec, frameMs = FRAME) => {
for (let t = 0; t < sec * 1000; t += frameMs) view.render(0, frameMs);
};
return { st, view, e, run, spr: () => view.sprites.get(e.id) };
};
const run = (sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, rules); };
let r = rig();
try {
const def = rules.unitById.fighter;
r.run(1.0);
let s = r.spr();
check('an idle aircraft renders on the ground', Math.abs(s.img.y - r.e.y) < 0.01,
`body ${(s.img.y - r.e.y).toFixed(2)}px off the ground`);
run(0.2);
view.render(0);
let s = view.sprites.get(e.id);
check('a grounded aircraft renders on the ground', Math.abs(s.img.y - e.y) < 0.01,
`body ${(s.img.y - e.y).toFixed(2)}px off the ground`);
check('a grounded aircraft casts no visible shadow', s.shadow.alpha < 0.001,
`alpha ${s.shadow.alpha.toFixed(3)}`);
// Giving it somewhere to be is what starts the climb — orders fill a tick before the sim
// has moved it anywhere, so the takeoff leads the movement rather than trailing it.
L.issueOrder(r.st, rules, {
army: 0, unitIds: [r.e.id], order: { type: 'move', x: r.e.x + 600, y: r.e.y },
});
r.run(0.1);
s = r.spr();
check('the climb starts before the unit has travelled', s.img.y < r.e.y - 0.5,
`only ${(r.e.y - s.img.y).toFixed(2)}px up after 0.1s`);
r.run(2.0);
s = r.spr();
const lift = r.e.y - s.img.y;
check('a moving aircraft reaches its full hover height',
L.issueOrder(st, rules, { army: 0, unitIds: [e.id], order: { type: 'move', x: e.x + 2000, y: e.y } });
run(2.0);
// Rendered at alpha 1 — an airborne unit is in motion now, so anything comparing sprite
// positions against the entity's CURRENT x/y has to sample the end of the tick, not a
// point interpolated back toward where it was.
view.render(1);
s = view.sprites.get(e.id);
const lift = e.y - s.img.y;
check('a flying aircraft is drawn at its full hover height',
Math.abs(lift - def.flight.height) < 0.5, `lifted ${lift.toFixed(1)} of ${def.flight.height}px`);
check('the shadow stays pinned to the true ground position',
Math.abs(s.shadow.x - r.e.x) < 0.01 && Math.abs(s.shadow.y - r.e.y) < 0.01);
Math.abs(s.shadow.x - e.x) < 0.01 && Math.abs(s.shadow.y - e.y) < 0.01);
check('the shadow separates from the body by the hover height',
Math.abs((s.shadow.y - s.img.y) - def.flight.height) < 0.5);
check('the airborne shadow is 50% transparent', Math.abs(s.shadow.alpha - 0.5) < 0.001,
`alpha ${s.shadow.alpha.toFixed(3)}`);
check('the shadow tightens as the unit climbs', s.shadow.scaleX < s.img.scaleX);
// Lifting the sprite must not re-sort it against its neighbours, or an aircraft would
// pop in front of things as it took off.
const depthUp = s.img.depth;
// Lifting the sprite must not re-sort it against its neighbours, or an aircraft would pop
// in front of things as it took off.
check('depth is taken from the ground position, not the lifted sprite',
Math.abs(depthUp - (DEPTHS.air + (r.e.y / r.st.worldH) * 10 + 0.05)) < 1e-6);
Math.abs(s.img.depth - (DEPTHS.air + (e.y / st.worldH) * 10 + 0.05)) < 1e-6);
// Landing: strip the orders the way arriving would, without running the sim.
r.e.orders.length = 0; r.e.movingTo = null; r.e.targetId = 0;
r.run(2.0);
s = r.spr();
check('an aircraft settles back onto the ground when idle',
Math.abs(s.img.y - r.e.y) < 0.01 && s.shadow.alpha < 0.001,
`body ${(s.img.y - r.e.y).toFixed(2)}px up, shadow alpha ${s.shadow.alpha.toFixed(3)}`);
// Altitude is interpolated between ticks exactly like position, so a climb is smooth at
// any framerate rather than stepping 20 times a second. Position is frozen here so the
// only thing differing between the two renders is the altitude.
e.px = e.x; e.py = e.y;
e.pliftFrac = 0; e.liftFrac = 1;
view.render(0);
const low = e.y - view.sprites.get(e.id).img.y;
view.render(1);
const high = e.y - view.sprites.get(e.id).img.y;
check('altitude interpolates between sim ticks', low < high - 1,
`alpha 0 gave ${low.toFixed(1)}px, alpha 1 gave ${high.toFixed(1)}px`);
view.destroy();
} catch (err) {
check('the takeoff animation runs', false, err.message);
}
r.view.destroy();
// Framerate independence. The animation advances on real elapsed time, so half a second of
// 60fps and half a second of 120fps must arrive at the same altitude — otherwise takeoff
// speed would silently depend on the player's hardware.
{
const a = rig(), b = rig();
try {
for (const g of [a, b]) {
L.issueOrder(g.st, rules, {
army: 0, unitIds: [g.e.id], order: { type: 'move', x: g.e.x + 600, y: g.e.y },
});
}
a.run(0.3, 1000 / 60);
b.run(0.3, 1000 / 120);
const la = a.e.y - a.spr().img.y, lb = b.e.y - b.spr().img.y;
check('takeoff is framerate independent', Math.abs(la - lb) < 1.0,
`60fps lifted ${la.toFixed(2)}px, 120fps lifted ${lb.toFixed(2)}px`);
} catch (err) {
check('the framerate-independence rig runs', false, err.message);
}
a.view.destroy(); b.view.destroy();
check('the altitude renderer runs', false, err.message);
}
}
@ -1432,34 +1399,46 @@ section('6f. Air domain and hover');
// ---- ground weapons cannot touch aircraft, and air-only weapons cannot touch the ground ----
{
// The Fighter carries the Jeep's gun for ground work, so it hurts infantry — but the
// infantry still cannot reach back, which is the asymmetry the whole domain split exists
// to create. It has to be flying for any of this: see §6g for the grounded case.
check('the air-to-air cannon cannot touch the ground', ar.weaponById.aacannon.targetsGround === false);
check('the Fighter carries the Jeep\'s gun for ground targets',
(ar.unitById.fighter.weapons ?? []).includes('pintlegun'));
const st = arena(6);
const inf = [];
for (let i = 0; i < 8; i++) inf.push(L.spawnUnit(st, ar, 0, 'infantry', px(8), px(14) + i * 30));
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(10), px(16));
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(14), px(16));
L.issueOrder(st, ar, { army: 0, unitIds: inf.map((u) => u.id), order: { type: 'attackMove', x: px(12), y: px(16) } });
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'hold' } });
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: inf[0].id } });
runFor(st, 60);
check('rifles cannot damage a Fighter', fighter.hp === fighter.maxHp,
`${fighter.hp.toFixed(0)}/${fighter.maxHp}`);
check('a Fighter cannot damage infantry', inf.every((u) => !u.dead && u.hp === u.maxHp));
check('a Fighter strafing infantry does hurt them',
inf.some((u) => u.dead || u.hp < u.maxHp),
'the ground gun never connected');
}
{
const st = arena(7);
const troopers = [];
for (let i = 0; i < 8; i++) troopers.push(L.spawnUnit(st, ar, 0, 'rockettrooper', px(8), px(14) + i * 30));
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(11), px(16));
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'hold' } });
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: troopers[0].id } });
runFor(st, 60);
check('shoulder rockets do reach a Fighter', fighter.dead || fighter.hp < fighter.maxHp,
`${fighter.hp.toFixed(0)}/${fighter.maxHp}`);
}
{
// Fighters are the answer to fighters; that is the whole point of an air-only weapon.
// They need somewhere to be — an idle aircraft lands, and a landed one cannot shoot.
const st = arena(8);
const a = L.spawnUnit(st, ar, 0, 'fighter', px(10), px(20));
const b = L.spawnUnit(st, ar, 1, 'fighter', px(13), px(20));
const b = L.spawnUnit(st, ar, 1, 'fighter', px(16), px(20));
L.issueOrder(st, ar, { army: 0, unitIds: [a.id], order: { type: 'attack', targetId: b.id } });
L.issueOrder(st, ar, { army: 1, unitIds: [b.id], order: { type: 'attack', targetId: a.id } });
runFor(st, 90);
check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp,
check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp || b.hp < b.maxHp,
`${a.hp.toFixed(0)} v ${b.hp.toFixed(0)}`);
}
@ -1468,12 +1447,17 @@ section('6f. Air domain and hover');
const st = arena(9);
const bomber = L.spawnUnit(st, ar, 0, 'bomber', px(8), px(20));
const tank = L.spawnUnit(st, ar, 1, 'tank', px(13), px(20));
// Parked directly over the tank: a 96px bomb blast covers it, and must not scratch it.
// Kept FLYING over the tank on a short patrol: a 96px bomb blast covers it, and must not
// scratch it while it is in the air. Idling it would land it, and a landed aircraft is
// deliberately no longer immune — see §6g.
const overhead = L.spawnUnit(st, ar, 1, 'fighter', px(13), px(20));
L.issueOrder(st, ar, { army: 0, unitIds: [bomber.id], order: { type: 'attack', targetId: tank.id } });
L.issueOrder(st, ar, { army: 1, unitIds: [overhead.id], order: { type: 'patrol', x: px(15), y: px(20) } });
runFor(st, 60);
check('a Bomber damages ground armour', tank.dead || tank.hp < tank.maxHp,
`${tank.hp.toFixed(0)}/${tank.maxHp}`);
check('the patrolling aircraft stayed airborne', overhead.dead || overhead.liftFrac === 1,
'the fixture landed it, so it proves nothing about splash');
check('bomb splash does not reach the aircraft above it', overhead.hp === overhead.maxHp,
`${overhead.hp.toFixed(0)}/${overhead.maxHp}`);
}
@ -1483,6 +1467,10 @@ section('6f. Air domain and hover');
const st = arena(10);
const inf = L.spawnUnit(st, ar, 0, 'infantry', px(10), px(20));
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(12), px(20));
// Airborne, so the rifleman genuinely cannot reach it. (Landed it would be a legal target;
// that is the whole point of §6g's grounded-aircraft checks.)
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'move', x: px(30), y: px(20) } });
L.tick(st, ar);
L.issueOrder(st, ar, { army: 0, unitIds: [inf.id], order: { type: 'attack', targetId: fighter.id } });
L.tick(st, ar);
check('an attack order on an unreachable domain is dropped', inf.orders.length === 0,
@ -1513,6 +1501,241 @@ section('6f. Air domain and hover');
}
}
// ---------------------------------------------------------------------------
section('6g. Strafing flight');
// ---------------------------------------------------------------------------
{
// An armed aircraft has exactly two states: LANDED (still, and unable to shoot) or FLYING (at
// cruise, always going somewhere). Every check here is about that having no third option —
// the failure mode this replaces is a Fighter parked in mid-air over a target, sniping it
// from a standstill, which is what the previous stop-on-arrival movement produced.
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
raw.constants.eliminateWhenUnrecoverable = false;
const sr = compileRules(raw);
const TS = sr.constants.tileSize;
const W = 64, H = 64;
check('armed aircraft are marked as strafing', sr.unitById.fighter.strafes === true
&& sr.unitById.bomber.strafes === true);
check('the Hover Constructor is exempt from strafing', sr.unitById.hoverconstructor.strafes === false,
'it is a ground unit that merely floats, and it does not attack');
const px = (t) => t * TS + TS / 2;
const arena = (seed = 3) => {
const st = L.createMatch(sr, {
seed, victory: 'annihilation',
map: { w: W, h: H, terrain: new Uint8Array(W * H).fill(sr.terrainById.ground.index), starts: [], theme: 'grasslands' },
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
});
// Both armies need something alive or checkResult ends the match on tick one — see §6f.
L.spawnUnit(st, sr, 0, 'infantry', px(1), px(1));
L.spawnUnit(st, sr, 1, 'infantry', px(W - 2), px(H - 2));
st.over = null;
for (const a of st.armies) a.alive = true;
return st;
};
const runFor = (st, sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, sr); };
const speedOf = (e) => Math.hypot(e.x - e.px, e.y - e.py) * HZ;
// ---- a move order ends in a landing, not a hover ----
{
const st = arena(31);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30));
check('a fresh aircraft starts on the ground', f.liftFrac === 0);
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(40), y: px(30) } });
runFor(st, 1.0);
check('an ordered aircraft takes off', f.liftFrac === 1, `liftFrac ${f.liftFrac.toFixed(2)}`);
runFor(st, 30);
check('a move order ends with the aircraft landed', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`);
check('it landed where it was sent', Math.hypot(f.x - px(40), f.y - px(30)) < TS * 2,
`${(Math.hypot(f.x - px(40), f.y - px(30)) / TS).toFixed(1)} tiles away`);
check('a landed aircraft is stationary', speedOf(f) < 0.01, `${speedOf(f).toFixed(1)} px/s`);
check('a landed aircraft holds no orders', f.orders.length === 0);
}
// ---- landed units cannot shoot ----
{
const st = arena(32);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
const foe = L.spawnUnit(st, sr, 1, 'infantry', px(32), px(30)); // well inside pintlegun range
runFor(st, 20);
check('a landed aircraft never takes off on its own', f.liftFrac === 0);
check('a landed aircraft does not shoot', foe.hp === foe.maxHp && !foe.dead,
`the target lost ${(foe.maxHp - foe.hp).toFixed(0)} hp`);
check('a landed aircraft holds no target', f.targetId === 0);
}
// ---- a landed aircraft is an ordinary ground target ----
{
// Immunity is a property of FLYING, not of having wings. A parked aircraft can be shot by
// anything, which is what gives the keepout rule and the choice of where to land teeth.
const st = arena(40);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30)); // tankgun is ground-only
L.tick(st, sr);
check('a landed aircraft is not an air target', f.airborne === false && f.isAir === true,
'isAir describes the layer it moves in; airborne describes where it is now');
runFor(st, 40);
check('a tank gun can destroy a parked aircraft', f.dead || f.hp < f.maxHp,
`${f.hp.toFixed(0)}/${f.maxHp}`);
}
{
// ...and the same aircraft is untouchable the moment it is off the ground.
const st = arena(41);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30));
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } });
runFor(st, 1.0);
check('taking off makes it an air target again', f.airborne === true && f.liftFrac === 1);
const hpAirborne = f.hp;
runFor(st, 25);
check('a tank gun cannot touch it once flying', f.hp === hpAirborne,
`lost ${(hpAirborne - f.hp).toFixed(0)} hp while airborne`);
check('and it kills the tank on its passes', tank.dead || tank.hp < tank.maxHp);
}
{
// Landing under guns is now a real mistake, not a free escape.
const st = arena(42);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
const tank = L.spawnUnit(st, sr, 1, 'tank', px(33), px(30));
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } });
runFor(st, 1.0);
const wasAir = f.airborne;
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'hold' } }); // set down
runFor(st, 2.0);
check('an aircraft told to hold leaves the air domain', wasAir === true && f.airborne === false,
`liftFrac ${f.liftFrac.toFixed(2)}`);
}
// ---- it never stops in the air ----
{
const st = arena(33);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
const foe = L.spawnUnit(st, sr, 1, 'tank', px(40), px(30));
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: foe.id } });
runFor(st, 3);
let slowest = Infinity, samples = 0;
for (let i = 0; i < 40 * HZ; i++) {
L.tick(st, sr);
if (f.dead || foe.dead) break;
if (f.liftFrac < 1) continue; // ignore the takeoff ramp
slowest = Math.min(slowest, speedOf(f));
samples++;
}
const cruise = sr.unitById.fighter.speed;
check('an attacking aircraft samples its speed', samples > 100, `${samples} samples`);
check('an attacking aircraft never slows down', slowest > cruise * 0.98,
`dropped to ${slowest.toFixed(0)} of ${cruise} px/s`);
}
// ---- the strafing run: overfly, carry through, come about, repeat ----
{
const st = arena(34);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
// A building, so it cannot run away and the geometry of the pass is unambiguous.
const tgt = L.placeBuilding(st, sr, 1, 'energygen', 40, 30);
tgt.site = false; tgt.progress = 1; tgt.hp = sr.buildingById.energygen.hp;
tgt.maxHp = tgt.hp;
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tgt.id } });
let closest = Infinity, farthest = 0, passes = 0, wasNear = false, egressSeen = false;
for (let i = 0; i < 60 * HZ; i++) {
L.tick(st, sr);
tgt.hp = tgt.maxHp; // immortal, so the run repeats indefinitely
if (f.liftFrac < 1) continue;
const d = Math.hypot(f.x - tgt.x, f.y - tgt.y);
closest = Math.min(closest, d);
farthest = Math.max(farthest, d);
if (f.egressing) egressSeen = true;
const near = d < TS * 3;
if (near && !wasNear) passes++;
wasNear = near;
}
check('the aircraft actually overflies its target', closest < TS * 2,
`closest approach ${(closest / TS).toFixed(1)} tiles`);
check('it carries through past the target', egressSeen && farthest > sr.unitById.fighter.flight.overshoot * 0.6,
`farthest ${farthest.toFixed(0)}px vs overshoot ${sr.unitById.fighter.flight.overshoot}`);
check('it comes about and runs in again', passes >= 3, `${passes} passes in 60s`);
check('it stays airborne throughout', f.liftFrac === 1);
}
// ---- opportunistic fire while transiting ----
{
const st = arena(35);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30));
// Ordered at something far away, with a bystander parked on the flight path.
const far = L.spawnUnit(st, sr, 1, 'tank', px(55), px(30));
const bystander = L.spawnUnit(st, sr, 1, 'infantry', px(25), px(30));
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: far.id } });
const startX = f.x;
runFor(st, 12);
check('a transiting aircraft shoots what it passes',
bystander.dead || bystander.hp < bystander.maxHp, 'the bystander was never engaged');
check('shooting a bystander does not stop the aircraft', f.x > startX + TS * 8,
`only travelled ${((f.x - startX) / TS).toFixed(1)} tiles`);
}
// ---- aircraft will not land in a hostile base ----
{
const st = arena(36);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
const base = L.placeBuilding(st, sr, 1, 'energygen', 40, 30);
base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp;
base.maxHp = base.hp;
// Told to move ONTO the enemy structure. That is not a move, it is an attack run.
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: base.x, y: base.y } });
L.tick(st, sr);
check('a move into a hostile base becomes an attack run',
f.orders[0]?.type === 'attack' && f.orders[0].targetId === base.id,
`order is ${f.orders[0]?.type}`);
let landed = false;
for (let i = 0; i < 40 * HZ; i++) {
L.tick(st, sr);
base.hp = base.maxHp;
if (f.liftFrac === 0) { landed = true; break; }
}
check('it never puts down inside the keepout', !landed,
`landed ${(Math.hypot(f.x - base.x, f.y - base.y) / TS).toFixed(1)} tiles from the base`);
}
// ---- an idle aircraft inside a keepout clears out, then lands ----
{
const st = arena(37);
const base = L.placeBuilding(st, sr, 1, 'energygen', 30, 30);
base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp;
const f = L.spawnUnit(st, sr, 0, 'fighter', px(32), px(30)); // parked right next to it
runFor(st, 40);
const away = L.surfaceDist(sr, f, base);
check('an idle aircraft leaves a hostile base before settling',
away >= sr.unitById.fighter.flight.keepout * 0.9,
`only got ${away.toFixed(0)}px from the base`);
check('and then it lands', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`);
}
// ---- the Hover Constructor keeps the old stop-in-place behaviour ----
{
const st = arena(38);
const h = L.spawnUnit(st, sr, 0, 'hoverconstructor', px(20), px(30));
L.issueOrder(st, sr, { army: 0, unitIds: [h.id], order: { type: 'move', x: px(26), y: px(30) } });
runFor(st, 30);
check('a hovering builder still stops exactly on its destination',
Math.hypot(h.x - px(26), h.y - px(30)) < TS, `${(Math.hypot(h.x - px(26), h.y - px(30)) / TS).toFixed(2)} tiles off`);
check('and settles back down when idle', h.liftFrac === 0);
}
// ---- altitude survives a save ----
{
const st = arena(39);
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(50), y: px(30) } });
runFor(st, 2);
const back = L.deserialize(sr, L.serialize(st));
const rf = back?.entities.find((e) => e.defId === 'fighter');
check('altitude survives serialization', !!rf && Math.abs(rf.liftFrac - f.liftFrac) < 0.01,
`${rf?.liftFrac} vs ${f.liftFrac}`);
}
}
// ---------------------------------------------------------------------------
section('7. Fog of war');
// ---------------------------------------------------------------------------
@ -1742,6 +1965,49 @@ section('12. AI skill ladder');
}
}
// ---------------------------------------------------------------------------
section('12b. AI build repertoire');
// ---------------------------------------------------------------------------
{
// Everything in the rules is dead data unless the AI will actually build it. Defences and
// the whole air branch were exactly that for a while: the Airfield gate was keyed on a mass
// income the AI never reaches (it peaks at 11-27, the bar was 24), so a skill-5 AI built one
// in zero games out of six while the roster claimed it could.
//
// Counts BOTH armies across several games — these are conditional behaviours, not a fixed
// build order, so the assertion is that each shows up across a sample rather than in any
// particular match.
const games = QUICK ? 6 : 12;
const built = Object.create(null);
for (let i = 0; i < games; i++) {
const map = generateMap(rules, { seed: 8000 + i * 13, size: 'medium', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 8000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
while (!st.over && st.tick < 900 * HZ) {
runAI(rules, st, 0, { skill: 4 });
runAI(rules, st, 1, { skill: 4 });
for (const ev of L.tick(st, rules)) {
if (ev.t === 'buildingComplete' || ev.t === 'spawn') {
built[ev.defId] = (built[ev.defId] ?? 0) + 1;
}
}
}
}
const n = (id) => (built[id] ?? 0);
console.log(` ${games} games · towers ${n('lasertower')} · launchers ${n('missilelauncher')}`
+ ` · airfields ${n('airfield')} · fighters ${n('fighter')} · bombers ${n('bomber')}`);
check('the AI builds Laser Towers', n('lasertower') > 0, 'never built one');
check('the AI builds Missile Launchers', n('missilelauncher') > 0, 'never built one');
check('the AI builds Airfields', n('airfield') > 0, 'never built one');
check('the AI produces aircraft', n('fighter') + n('bomber') > 0, 'never built any');
// Defence is a reaction, not a habit. An AI that answers every game with a wall of towers
// has stopped building an army, and the skill ladder above is measuring the wrong thing.
check('defence stays a minority of construction',
n('lasertower') + n('missilelauncher') < n('energygen') + n('massgen'),
`${n('lasertower') + n('missilelauncher')} defences vs ${n('energygen') + n('massgen')} generators`);
// Decisiveness is §11's job: it uses parameters tuned for it, whereas two equal skill-4 AIs
// on a mirrored map are supposed to be able to draw.
}
// ---------------------------------------------------------------------------
section('13. Campaign winnability');
// ---------------------------------------------------------------------------