TAL: smooth obstacle collision push + in-place building upgrades (upgradesFrom)

Movement & collision:
- stepSeparation (TALogic) replaces the hard "snap unit to nearest clear
  tile centre" correction with a circle-vs-blocked-tile push proportional
  to overlap, damped by separationStiffness — no more teleport-yank,
  corner-bouncing, or stalling in exact-width corridors (caught as an
  AI-vs-AI win-rate regression in testing).
- servicePathQueue now routes at a unit's exact required clearance instead
  of padding +1, so traffic spreads across all passable tiles rather than
  funneling into a few wide chokepoint corridors.
- TAArt's structure painter falls back to a generic spinning glyph for
  topperFrame buildings.

Upgrades (new rule: upgradesFrom):
- A building may be placed directly on top of a friendly building it
  names at the same tile/footprint, consuming it for a 50% refund of the
  replaced building's OWN cost (not a price discount) — implemented via
  TALogic.findUpgradeTarget and an army-aware canPlaceAt.
- New definitions: nuclearplant (upgrades energygen, frame 22) and
  advancedmassgen (upgrades massgen, frame 24).
- compileRules (TARules) validates upgradesFrom references and topperFrame;
  sprites.md documents the semantics.
- New map m06 "Annihilation" (medium, snowfields, Klaxon skill 5, seed
  90123 — pre-vetted from the original sweep for early-economy balance).

Tests (tools/verifyTotalAnnihilation.js):
- Section 4d: pathfinding routes around obstacles and a unit threads a
  corridor exactly its own width.
- Section 5b: obstacle push is a bounded per-tick nudge (not a
  teleport), settles clear without oscillation, a unit ordered past an
  obstacle arrives, and no stuck-give-up flagging.
- Upgrade suite: exact 50% refund credited in place, fresh-ground builds
  still work, enemy-owned and mismatched-type targets rejected, reverse
  nuclearplant/energygen pairing, and canPlaceAt without an army never
  grants upgrade placements.
This commit is contained in:
Brian Fertig 2026-08-16 18:59:30 -06:00
parent 3c7184564a
commit 347957bd84
4 changed files with 431 additions and 120 deletions

View File

@ -787,7 +787,7 @@
"id": "m06",
"name": "Annihilation",
"theme": "snowfields",
"seed": 80808,
"seed": 90123,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
@ -835,102 +835,102 @@
"h": 96,
"theme": "snowfields",
"rows": [
"~~~r....r...rrrrrr..........r.~~~~~~...............^^^^^^^^^^^^^^..................r....r..^^^^^",
"~~~r..........r....MM.........~~~~~~...............^^^^^^^^^^^^^...........................^^^^^",
"~~~...............rMM.........~~~~~r................^^^^^^^^^^^............................^^^^^",
"~~~...............rr..............r...................^^^^^^............................rr.^^^^^",
"rr................rrrr....................................r..........rr.................^^^^^^^^",
"...................rrrr.r............................................rr................^^^^^^^^^",
"...................rrrrrrrr............................................................^^^^^^^^^",
"....................r...rr..................r...........................................^^^^^^rr",
"r.....MM...................................r^^................rr........................^^^^^^..",
"......MM.............^^^..................^^^^^............rrrrrrrrr...................^^^^^^^^.",
"..MM................^^^^^^.............rr^^^^^^^...........rrrrrrrrr.................r^^^^^^^^^^",
"..MM...............^^^^^^^...rr........r^^^^^^^^^............r....^^r................^^^^^^^^^^^",
"...................^^^^^^^..rrr..rr....r^^^^^^^^^.................^^^................^^^^^^^^^^^",
"..................r^^^^^^^rrrrrr.rr....r^^^^^^^^^..................^........rr.......^^^^^^^^^^^",
"..................r^^^^^^...rrrr..........^^^^^^rr.....rrrrrrr..............rrrrr.....^^^^^^^^^.",
"..................^^^^^^....rrrrr................rrrrr~~~~~rrr..............rrrrr........r...r..",
".................^^^^^^r...rrr.rr..~.............rrr~~~~~~~~~.............................rrr...",
"~..............^^^^^.^.....rr....~~~~.rrr........rrr~~~~~~~~~..............................r....",
"~~.........r^^^^^^..............~~~~..rrr.............~~~~~~....................................",
"~~MM.......^^^^^...............~~~~........................rrrrrr...............................",
"~~MM.......^^^^^..............~~~~~........................rrrrrr...............................",
"~~~.......r^^^^^.............~~~~~~.........................rrrr................................",
"~~........^^^^^^^.............~~~~~.............................................................",
"..........^^^^^^^^..............~~~.............................................rrr.....r^......",
".........r^^^^^^^^................r.............................................rrr....^^^^.....",
".........r^^^^^^^^rrrr.....r....................................................rrrr...^^^^^....",
"r..........^^^^^^^rrrr....rrr......................................................rr..^^^^^....",
"r.............rrr....rrrrr.rr......................................................rrrrr^^^.....",
".rrrr........rrrr.......^^..r.......................................................rr..........",
"..rrrr.......rr........^^^^^^.........................r...r...............................rrr...",
"^^.rrr............rr..r^^^^^^r.......................^^^^^^^............................^^^^^^^^",
"^^.rrrr...........rrrr.^^^^^.......................^^^^^^^^^^^^.r.......................^^^^^^^^",
"^^rrrrr........rrrr...^^^^^.....^^^^^.............^^^^^^^^^^^^^^^rr....................^^^^^^^^^",
"^^rrr..........rrr^...^^^^.....r^^^^^.............^^^^^^^^^^^^^^^^^....................^^^^^^^^^",
"^^.rr..........rrr....^^^r.....r^^^^...............^^^^^^^^^^^^^........................^^^^^^^^",
"^..rr..rr...............r.......^^..................^^^^^^^^^..............rrr..................",
"....r.rrr...............r...^^^^.....................r^^^^................rrrr~.................",
".....rrrr..............rrr..^^^^.....................rr...................rrrr~.................",
"r......rr...............r...^^^^.....................rr.........................................",
".r...........................^^^^^^...r...........rrr........................................rr.",
".rr..........................^^^^^^^rrrr..........rrr.......................................rrr.",
"..r...................~~~....^^^^^^^^^^r.r..........r.......................................rrr.",
"~~~....rrr..........~~~~~~...^^^^^^^^^^^^^^^^^^.............................................rrrr",
"~~~~...rrrr........~~~~~~~....^^^^^^^^^^^^^^^^^^..........rr..................................~~",
"~~~~...rrrr........~~~~~~~....^^^^^^^^^^^^^^^^^^^.........rr......~~~........................~~~",
"~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^^^..........r......~~~........................~~~",
"~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^^.r........rr......~~..........................~~",
"~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^..rrrrr....rr..r...~~.....r....................~~",
"~~....................r.....~~...r..rr....rrrrr..^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
"~~..........................~~......rr........r.^^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
"~~~........................~~~......r..........^^^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
"~~~........................~~~......rr.........^^^^^^^^^^^^^^^^^^^....~~~~~~~........rrrr...~~~~",
"~~..................................rr..........^^^^^^^^^^^^^^^^^^....~~~~~~~........rrrr...~~~~",
"rrrr.............................................^^^^^^^^^^^^^^^^^^...~~~~~~..........rrr....~~~",
".rrr.......................................r..........r.r^^^^^^^^^^....~~~...................r..",
".rrr.......................................rrr..........rrrr^^^^^^^..........................rr.",
".rr........................................rrr...........r...^^^^^^...........................r.",
".........................................rr.....................^^^^...r...............rr......r",
".................~rrrr...................rr.....................^^^^..rrr..............rrrr.....",
".................~rrrr................^^^^r.....................^^^^...r...............rrr.r....",
"..................rrr..............^^^^^^^^^..................^^.......r...............rr..rr..^",
"^^^^^^^^........................^^^^^^^^^^^^^...............^^^^r.....r^^^....rrr..........rr.^^",
"^^^^^^^^^....................^^^^^^^^^^^^^^^^^.............^^^^^r.....^^^^...^rrr..........rrr^^",
"^^^^^^^^^....................rr^^^^^^^^^^^^^^^.............^^^^^.....^^^^^...rrrr........rrrrr^^",
"^^^^^^^^.......................r.^^^^^^^^^^^^.......................^^^^^.rrrr...........rrrr.^^",
"^^^^^^^^............................^^^^^^^.......................r^^^^^^r..rr............rrr.^^",
"...rrr...............................r...r.........................^^^^^^........rr.......rrrr..",
"..........rr.......................................................r..^^.......rrrr........rrrr.",
".....^^^rrrrr......................................................rr.rrrrr....rrr.............r",
"....^^^^^..rr......................................................rrr....rrrr^^^^^^^..........r",
"....^^^^^...rrrr....................................................r.....rrrr^^^^^^^^r.........",
".....^^^^....rrr.............................................r................^^^^^^^^r.........",
"......^r.....rrr.............................................~~~..............^^^^^^^^..........",
".............................................................~~~~~.............^^^^^^^........~~",
"................................rrrr.........................~~~~~~.............^^^^^r.......~~~",
"...............................rrrrrr........................~~~~~..............^^^^^.......MM~~",
"...............................rrrrrr........................~~~~...............^^^^^.......MM~~",
"....................................~~~~~~.............rrr..~~~~..............^^^^^^r.........~~",
"....r..............................~~~~~~~~~rrr........rrr.~~~~....rr.....^.^^^^^..............~",
"...rrr.............................~~~~~~~~~rrr.............~..rr.rrr...r^^^^^^.................",
"..r...r........rrrrr..............rrr~~~~~rrrrr................rrrrr....^^^^^^..................",
".^^^^^^^^^.....rrrrr..............rrrrrrr.....rr^^^^^^..........rrrr...^^^^^^r..................",
"^^^^^^^^^^^.......rr........^..................^^^^^^^^^r....rr.rrrrrr^^^^^^^r..................",
"^^^^^^^^^^^................^^^.................^^^^^^^^^r....rr..rrr..^^^^^^^...................",
"^^^^^^^^^^^................r^^....r............^^^^^^^^^r........rr...^^^^^^^...............MM..",
"^^^^^^^^^^r.................rrrrrrrrr...........^^^^^^^rr.............^^^^^^................MM..",
".^^^^^^^^...................rrrrrrrrr............^^^^^..................^^^.............MM......",
"..^^^^^^........................rr................^^r...................................MM.....r",
"rr^^^^^^...........................................r..................rr...r....................",
"^^^^^^^^^............................................................rrrrrrrr...................",
"^^^^^^^^^................rr............................................r.rrrr...................",
"^^^^^^^^.................rr..........r....................................rrrr................rr",
"^^^^^.rr............................^^^^^^...................r..............rr...............~~~",
"^^^^^............................^^^^^^^^^^^................r~~~~~.........MMr...............~~~",
"^^^^^...........................^^^^^^^^^^^^^...............~~~~~~.........MM....r..........r~~~",
"^^^^^..r....r..................^^^^^^^^^^^^^^...............~~~~~~.r..........rrrrrr...r....r~~~"
"^^^^.......~~~.....r..r.....r....~~.....^^^^^^^^^^^........rr...........^^^^^^^^^^^^^^^^^^^^^^^^",
"^^^^.......~~~..................~~~.....^^^^^^^^^^^......................^^^^^^^^^^^^^^^^^^^^^^^",
"^^^^.......~~r.................~~~~......^^^^^^^^^.....rr................^^^^^^^^^^^^^^^^^^^^^^^",
"^^^^..MM.....................r~~~~~~.....^^^^^^^^^.....rr..................^^^^^^^^^^^^^^^^^^^^^",
"^^^^..MM......MM............r~~~~~~~r....^^^^^^^^^rrr.......................^^^^^^^^^^^^..^^^^^^",
"^^^^..........MM.............r~~~~~rr.....^^^^^^^^..rr........................r..............r..",
"^^^^..........................rrrr.rr......^^^^.....rr..........................................",
"^^^............................r...............................r~~~~~~~~........................",
"^^^.....................~~.....................................~~~~~~~~~~~~~~...................",
"^^^....................~~~.......rr............................~~~~~~~~~~~~~~~~~~~~~~~~~~~~rr...",
"^^.....................~~~~......rrr............................~~~~~~~~~~~~~~~~~~~~~~~..rrrr...",
"^^^..MM...........~~~~~~~~~...rrrrr................^^^^^^^r......~~~~~~~~~~~.............rrrr...",
"^^^..MM...........~~~~~~~~~rrrrrrrr...............^^^^^^^^^......r~~~~~rrr................rr....",
"^^^...............~~..~~~~~rr.....................^^^^^^^^..rr....~~~~~.r.......................",
"............................r.......................^^^^^^..rr....~~~~.rrr......................",
"............................rr........................^^^...rr....~~~~rrrr......................",
".............................r....................................~~~~..........................",
".............................rrr..................................~~~...........................",
"............................rrrr..................rrr.............~~...........................r",
"~~MMrr.....rr................rr...................rrr..............r............................",
"~~MMrrrrrrrrrrr.........................rr......................rrr.............................",
"~~~rrrrrrrrr^^^r..rr...................rrr......................rrr.............................",
"~~~rrrrrrrrr^^rr..rr....................rr................rr............................rr......",
"~~~rr..rrrrrrrrr.......................................rrrrr........rr...............rrrrr.....r",
"~~.....rr..r....r......................................rrrrr...rr..rrr.......^^^.....rrr........",
"~~.....rr......rr......................................rrrr....rr...r........^^^......rr........",
"~~.....................................................rrrr....r....rr...............rrr........",
"................................................................~~~~~~..............rrr.........",
".................................~.......~~~~~~~r............~~~~~~~~~~~............r...........",
"...............rr...rr..........~~~...~~~~~~~~~~~~..........~~~~~~~~~~~~..........rr...........r",
"...............rr..rrr...........~~...~~~~~~~~~~~~...........~~~~~~~~~~~........rrr.............",
".rr............rr...rr.........r........~~~~~~~~~.............~~~~~~~~~.........rr..............",
"rrr............rrr....rrr.....rrr.........r~~~~................~~~~~~~.......^^^..............rr",
"r.....................rrrr..rr..r...........~~..................~~~~~~......^^^^^^...r.rr....rr.",
"r......................r.rrrrr..r...............................~~~~~~.......^^^^^..rrrrr....rr.",
"...rr..........................rr......................rr........~~~~........^^^^....rrr........",
"...rr..........................rr.....................rrrr........r...................r.........",
".......................r...............................rrr.................r.........rrr........",
".......................r~~.............................rrr.......rr........rrrr.....rrrr........",
".....................~~~~~~......rr..............................rr........rrrr....rrrr.........",
"r...................~~~~~~~~.....rr..............................rrr.......rrr......rr..........",
"r..................~~~~~~~~~~...rrr........^^^^...................rr........rr......rr..........",
"~~.................~~~~~~~~~~~..r.........^^^^^^......rrr........^^^^^.............rrrr..^......",
"~~~...............~~~~~~~~~~~~~r.........^^^^^^^^....rrrr......r^^^^^^^.............rrrr^^^.....",
"~~~~..............~~~~~~~~~~~~~..........^^^^^^^^r...r.........^^^^^^^^..............rrr^^^.....",
"~~~~..............~~~~~~~~~~~~~..........^^^^^^^^..............^^^^^^^^..............rrr^^^.....",
"~~~~..............~~~~~~~~~~~~r..........^^^^^^^^...............^^^^^^^..............rrr^^^.....",
"~~~r....rr........~~~~~~~~~~~.r.........^^^^^^^^^.....rr........r^^^^^...............rrr........",
"........rrr...............^^^^^r........rr.....^^^^^^^^^.........r.~~~~~~~~~~~........rr....r~~~",
".....^^^rrr..............^^^^^^^...............^^^^^^^^..........r~~~~~~~~~~~~..............~~~~",
".....^^^rrr..............^^^^^^^^..............^^^^^^^^..........~~~~~~~~~~~~~..............~~~~",
".....^^^rrr..............^^^^^^^^.........r...r^^^^^^^^..........~~~~~~~~~~~~~..............~~~~",
".....^^^rrrr.............^^^^^^^r......rrrr....^^^^^^^^.........r~~~~~~~~~~~~~...............~~~",
"......^..rrrr.............^^^^^........rrr......^^^^^^.........r..~~~~~~~~~~~.................~~",
"..........rr......rr........rr...................^^^^........rrr...~~~~~~~~~~..................r",
"..........rr......rrr.......rrr..............................rr.....~~~~~~~~...................r",
".........rrrr....rrrr........rr..............................rr......~~~~~~.....................",
"........rrrr.....rrrr........rr.......rrr.............................~~r.......................",
"........rrr.........r.................rrr...............................r.......................",
".........r...................r........rrrr.....................rr..........................rr...",
"........rrr....^^^^........~~~~........rr......................rr..........................rr...",
".rr....rrrrr..^^^^^.......~~~~~~...............................r..rrrrr.r......................r",
".rr....rr.r...^^^^^^......~~~~~~..................~~...........r..rr..rrrr.....................r",
"rr..............^^^.......~~~~~~~................~~~~r.........rrr.....rrr....rrr............rrr",
"..............rr.........~~~~~~~~~.............~~~~~~~~~........r.........rr...rr............rr.",
".............rrr........~~~~~~~~~~~...........~~~~~~~~~~~~...~~...........rrr..rr...............",
"r...........rr..........~~~~~~~~~~~~..........~~~~~~~~~~~~...~~~..........rr...rr...............",
"...........r............~~~~~~~~~~~............r~~~~~~~.......~.................................",
".........rrr..............~~~~~~................................................................",
"........rrr...............rr....r....rrrr.....................................................~~",
"........rr......^^^........r...rr....rrrr......................................rr......rr.....~~",
"........rrr.....^^^.......rrr..rr...rrrrr......................................r....r..rr.....~~",
"r.....rrrrr...............rr........rrrrr.......................................rrrrrrrrr..rr~~~",
"......rr............................rr................rr....................rr..rr^^rrrrrrrrr~~~",
".............................rrr......................rrr...................rr..r^^^rrrrrrrrr~~~",
".............................rrr......................rr.........................rrrrrrrrrrrMM~~",
"............................r..............rrr...................rr................rr.....rrMM~~",
"r...........................~~.............rrr..................rrrr............................",
"...........................~~~..................................rrr.............................",
"..........................~~~~....................................r.............................",
"......................rrrr~~~~....rr...^^^........................rr............................",
"......................rrr.~~~~....rr..^^^^^^.......................r............................",
".......................r.~~~~~....rr..^^^^^^^^.....................rr~~~~~..~~...............^^^",
"....rr................rrr~~~~~r......^^^^^^^^^...............rrrrrrrr~~~~~~~~~...........MM..^^^",
"...rrrr.............~~~~~~~~~~~......r^^^^^^^................rrrrr...~~~~~~~~~...........MM..^^^",
"...rrrr..~~~~~~~~~~~~~~~~~~~~~~~............................rrr......~~~~.....................^^",
"...rr~~~~~~~~~~~~~~~~~~~~~~~~~~~~............................rr.......~~~....................^^^",
"...................~~~~~~~~~~~~~~.....................................~~.....................^^^",
"........................~~~~~~~~r...............................r............................^^^",
"..........................................rr.....^^^^......rr.rrrr..........................^^^^",
"..r..............r........................rr..^^^^^^^^.....rr~~~~~r.............MM..........^^^^",
"^^^^^^..^^^^^^^^^^^^.......................rrr^^^^^^^^^....r~~~~~~~r............MM......MM..^^^^",
"^^^^^^^^^^^^^^^^^^^^^..................rr.....^^^^^^^^^.....~~~~~~r.....................MM..^^^^",
"^^^^^^^^^^^^^^^^^^^^^^^................rr.....^^^^^^^^^......~~~~.................r~~.......^^^^",
"^^^^^^^^^^^^^^^^^^^^^^^......................^^^^^^^^^^^.....~~~..................~~~.......^^^^",
"^^^^^^^^^^^^^^^^^^^^^^^^...........rr........^^^^^^^^^^^.....~~....r.....r..r.....~~~.......^^^^"
],
"starts": [
{

View File

@ -13,7 +13,7 @@
import {
createNav, stampFootprint, clearanceFor, findPath, smoothPath, formationSlots,
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY, fitField,
SpatialHash, segmentClear,
} from './TANav.js';
import { canEngage, weaponHitsDomain } from './TARules.js';
@ -791,14 +791,22 @@ function servicePathQueue(state, rules) {
const nav = state.nav;
const need = clearanceFor(e.radius, state.tileSize);
const mc = def.moveClass;
e.wantPath = false;
// Route at exact-fit clearance rather than padding it out with extra breathing room. Padding
// was tried and measurably backfired: it makes every unit prefer the same handful of wide
// corridors instead of spreading across every tile that's merely wide enough, which
// concentrates traffic at chokepoints far more than exact-fit routing ever did — confirmed by
// a skill5-vs-skill4 AI ladder dropping from a healthy ~55% down to ~35% with padding on,
// using identical seeds, isolating it from the obstacle-push change tested alongside it.
// stepSeparation's obstacle push (pushOutOfObstacles) is what actually smooths a tight-cut
// path's corner-grazing now, so paths don't need to route around it defensively anymore.
const sIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.x), worldToTileY(nav, e.y), 6);
const gIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.destX), worldToTileY(nav, e.destY), 24);
e.wantPath = false;
if (sIdx < 0 || gIdx < 0) { e.noPath = true; e.path = null; continue; }
const tiles = findPath(nav, mc, need, sIdx, gIdx);
if (!tiles) { e.noPath = true; e.path = null; state.events.push({ t: 'noPath', id: e.id }); continue; }
e.path = smoothPath(nav, mc, need, tiles, e.destX, e.destY);
e.pathIdx = 0;
}
@ -1492,35 +1500,123 @@ function stepSeparation(state, rules) {
}
}
// Push everything out of blocked tiles and back inside the map.
// Push everything out of obstacles and back inside the map.
const nav = state.nav;
const ts = state.tileSize;
for (const e of movers) {
const def = defOf(rules, e);
// Aircraft are only bounded by the map edge — nothing on the grid blocks them, and the
// clamp below would otherwise drag one off a cliff or a building it was legitimately over.
// push below would otherwise drag one off a cliff or a building it was legitimately over.
if (e.isAir) {
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y));
continue;
}
const need = clearanceFor(e.radius, ts);
const cl = nav.clearance[def.moveClass];
const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y);
if (cl[ty * nav.w + tx] < need) {
const idx = nearestUsableTile(nav, def.moveClass, need, tx, ty, 8);
if (idx >= 0) {
const cx = tileCenterX(nav, idx % nav.w), cy = tileCenterY(nav, (idx / nav.w) | 0);
const d = Math.hypot(cx - e.x, cy - e.y) || 1;
e.x += ((cx - e.x) / d) * Math.min(ts * 0.5, d);
e.y += ((cy - e.y) / d) * Math.min(ts * 0.5, d);
}
}
pushOutOfObstacles(nav, ts, e, def, stiff);
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y));
}
}
/**
* Smooth circle-vs-obstacle push, replacing what used to be a hard per-tile teleport (if the
* unit's CENTRE tile didn't have enough clearance, snap it toward the nearest tile that did)
* with the same overlap-and-push idiom the unit-vs-unit pairs above already use. A unit
* grazing a building corner which pathfinding routinely produces, since a path only has to
* clear the grid's clearance test, not a true circle-vs-rectangle one now gets nudged away
* gradually in proportion to how deep it's in, rather than being yanked to a tile centre and
* immediately driving back toward the same waypoint. Some visible overlap at the moment of
* contact is the deliberate trade: it reads as a unit brushing past a corner instead of
* bouncing off it. The per-tile correction below is damped by the same `separationStiffness`
* the unit-vs-unit push above uses, rather than snapping the full overlap depth in one tick
* without that damping this settles a unit against ONE obstacle tile in a single full-strength
* step, and a unit passing between two obstacle tiles close together (an exact-width corridor
* routine here, since paths route at exact-fit clearance, see servicePathQueue) gets bounced at
* full strength between them every tick instead of converging, stalling it in the gap. This
* showed up as a measurable AI-vs-AI win-rate regression before being caught.
*
* Generalises over buildings AND blocking terrain uniformly by working straight off
* `nav.passable[mc]` (already move-class-aware hover over water reads passable) rather than
* needing per-building entity lookups: every blocked tile within the unit's own physical reach
* is a square, and squares-vs-circle is cheap. This deliberately does NOT reuse the
* `clearance`/`fitField` machinery pathfinding relies on that answers "is there enough ROOM
* for a unit this wide to path through here at all", a per-tile abstraction with no notion of
* where within the tile the unit actually is; this answers "is this unit's real circular body,
* wherever it currently sits, overlapping a specific blocked square", which is what a runtime
* position correction actually needs.
*/
function pushOutOfObstacles(nav, ts, e, def, stiff) {
const pass = nav.passable[def.moveClass];
const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y);
const need = clearanceFor(e.radius, ts);
// The unit's own tile doesn't have room for something its size to stand on AT ALL — not
// just literally blocked, but too narrow even where technically passable (e.g. squeezed into
// a one-tile gap that only just fits it). This has to use `fitField`, the same DILATED test
// pathfinding and `nearestUsableTile` itself use — `nav.clearance[mc]` alone answers "is THIS
// tile the top-left anchor of a big-enough square", which is wrong for any need>1 unit
// standing anywhere else in a perfectly open block (the bottom/right three tiles of a clear
// 2x2 read as "not clear" under the raw array even though the unit fits fine). For need=1
// (every unit before the Megatank) `fitField` degenerates to `nav.clearance[mc]` exactly, so
// this was invisible until a unit with radius>tileSize/2 shipped — a Megatank standing on
// legitimately open ground next to any building read as "stuck" on nearly every tick, got
// spuriously teleport-corrected toward a nearby anchor tile's centre, and that correction
// fought stepMovement's real path-following hard enough to pin it in place (still turning,
// since heading tracks the path waypoint independently of whether the unit actually gets
// there). This also has to stay a directed grid search rather than local geometry: pushing
// away from one blocking neighbour can be exactly cancelled by an opposite push from another,
// holding a unit motionless in a symmetric pinch instead of resolving either way — confirmed
// by a unit spawned on a map's cliff tile freezing in place forever under a naive "back away
// from whichever blocked square is nearest" rule. So this keeps the pre-existing rescue, the
// same nearestUsableTile spiral this whole function used to run for every correction, for
// exactly the cases where "usable" isn't just "unblocked".
const cl = fitField(nav, def.moveClass, need);
if (cl[ty * nav.w + tx] < need) {
const idx = nearestUsableTile(nav, def.moveClass, need, tx, ty, 8);
if (idx >= 0) {
const cx = tileCenterX(nav, idx % nav.w), cy = tileCenterY(nav, (idx / nav.w) | 0);
const d = Math.hypot(cx - e.x, cy - e.y) || 1;
e.x += ((cx - e.x) / d) * Math.min(ts * 0.5, d);
e.y += ((cy - e.y) / d) * Math.min(ts * 0.5, d);
}
return;
}
// The unit's own ground genuinely has room for it — it's at most grazing a NEIGHBOURING
// blocked tile, which is where the smooth push actually applies.
const reach = Math.ceil(e.radius / ts) + 1;
const y0 = Math.max(0, ty - reach), y1 = Math.min(nav.h - 1, ty + reach);
const x0 = Math.max(0, tx - reach), x1 = Math.min(nav.w - 1, tx + reach);
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
if (pass[y * nav.w + x]) continue;
// Nearest point on this tile's square to the unit's centre — applied immediately (not
// accumulated) so a unit wedged in a concave corner resolves against each blocking tile
// in turn off its own, already-adjusted position, the same way the pairwise loop above
// settles a knot of several units one pair at a time rather than all at once.
const left = x * ts, top = y * ts;
const nx = Math.max(left, Math.min(e.x, left + ts));
const ny = Math.max(top, Math.min(e.y, top + ts));
const dx = e.x - nx, dy = e.y - ny;
const d2 = dx * dx + dy * dy;
if (d2 >= e.radius * e.radius) continue;
const d = Math.sqrt(d2);
if (d < 0.0001) {
// The tile containing the unit's centre passed the check above, so this is a
// neighbouring blocked tile the unit is exactly astride the edge of — vanishingly rare,
// and still has no defined push direction, so treat it the same as landing dead-centre
// on one: back away from wherever it's currently facing.
e.x -= Math.cos(e.heading) * e.radius * stiff;
e.y -= Math.sin(e.heading) * e.radius * stiff;
continue;
}
const push = (e.radius - d) / d * stiff;
e.x += dx * push;
e.y += dy * push;
}
}
}
function shove(state, rules, e, nx, ny) {
e.blockedTicks = 0;
const ts = state.tileSize;

View File

@ -92,12 +92,13 @@ const MISSIONS = [
defeat: 'The push came faster than the plant did.',
},
{
// Re-seeded once, 8-run sweep: 63541 (fell to 0/8) -> 80808 (8/8) when static defence
// became a skill-3+ behaviour with a PROACTIVE first tower (TAAI.urgentDefence no longer
// waits for a raid to build the first one). KLAXON here is skill 5, well past that gate,
// so it now stands up a tower before the player-bot's rush can land — 90123 and 56789 also
// scored 8/8 in the same sweep if this needs revisiting again.
id: 'm06', name: 'Annihilation', seed: 80808, size: 'medium', theme: 'snowfields', symmetry: 'rotational',
// Re-seeded twice. First: 63541 (fell to 0/8) -> 80808 (8/8) when static defence became a
// skill-3+ behaviour with a PROACTIVE first tower. Second: 80808 (fell to 0/8) -> 90123
// (8/8) when the movement/collision rework (smooth obstacle push replacing hard tile-snap,
// pathfinding routing at exact-fit clearance instead of padded) changed this specific map's
// early economy enough that the player-bot's mass income never recovered — a pre-vetted
// fallback from the same original sweep. 56789 also scored 8/8 if this needs revisiting.
id: 'm06', name: 'Annihilation', seed: 90123, size: 'medium', theme: 'snowfields', symmetry: 'rotational',
enemy: { commander: 'klaxon', skill: 5, aggression: 0.85 },
startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }],
prebuilt: [

View File

@ -826,6 +826,94 @@ section('4. Pathfinding');
segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts));
}
// ---------------------------------------------------------------------------
section('4d. Pathfinding — routes around obstacles and threads a tight gap');
// ---------------------------------------------------------------------------
{
// servicePathQueue (TALogic.js) routes at the bare `need` clearance a unit's own width
// requires. It used to try `need + 1` first for extra breathing room off a corner, but that
// padding measurably backfired — it funnels every unit onto the same handful of wide corridors
// instead of spreading across every tile that's merely wide enough, which concentrates traffic
// at chokepoints far more than exact-fit routing does (confirmed via a skill5-vs-skill4 AI
// ladder on identical seeds: ~55% decided-game win rate with padding off, ~35% with it on).
// stepSeparation's obstacle push (pushOutOfObstacles) is what smooths a tight-cut path's
// corner-grazing now, so paths don't need to route around it defensively.
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no commander
const pr = compileRules(raw);
const ts = pr.constants.tileSize;
const mkMap = (rows) => {
const w = rows[0].length, h = rows.length;
const terrain = new Uint8Array(w * h);
const wall = pr.terrainByCh['^'].index, open = pr.terrainByCh['.'].index;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open;
return { w, h, terrain, starts: [], theme: pr.skirmish.defaults.theme };
};
// An obstacle with open ground on both sides of it — the direct line from start to goal
// crosses it, so a route has to detour either way, and there is enough room on both sides
// to detour with a full tile of clearance rather than hugging it at exactly one. Padded out
// to 19x12 (rows/cols 7+ are pure dead space, built programmatically so the width can't drift
// out of sync by a hand-miscounted row) purely to give the army-1 decoy below genuine
// distance from both the tank's path and its destination — measured in real px, not corners.
const W1 = 19, H1 = 12;
const rows1 = Array.from({ length: H1 }, () => '.'.repeat(W1));
rows1[3] = `${rows1[3].slice(0, 5)}##${rows1[3].slice(7)}`;
rows1[4] = `${rows1[4].slice(0, 5)}##${rows1[4].slice(7)}`;
const map = mkMap(rows1);
const st = L.createMatch(pr, { seed: 61, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
st.over = null; for (const a of st.armies) a.alive = true;
// Army 1 needs SOMETHING alive or checkResult ends the match on tick one (it's "wiped", not
// just commander-less) and every tick after that silently no-ops. Genuinely far — a nearer
// "far corner" of the original small map turned out to be well within tank gun range of the
// tank's own destination in the §5b fixture, and it killed the decoy before ever arriving.
L.spawnUnit(st, pr, 1, 'infantry', 18 * ts + ts / 2, 11 * ts + ts / 2);
const tank = L.spawnUnit(st, pr, 0, 'tank', 1 * ts + ts / 2, 3 * ts + ts / 2);
const destX = 11 * ts + ts / 2, destY = 3 * ts + ts / 2;
const r = L.issueOrder(st, pr, { army: 0, unitIds: [tank.id], order: { type: 'move', x: destX, y: destY } });
check('move order accepted for the padding fixture', r.ok, r.error);
let path = null;
for (let i = 0; i < 20 && !path; i++) { L.tick(st, pr); if (tank.path?.length) path = tank.path; }
check('a path was computed', !!path);
const need = clearanceFor(tank.radius, ts);
let clearThroughout = !!path;
for (let i = 0; path && i + 3 < path.length; i += 2) {
if (!segmentClear(st.nav, 'tread', need, path[i], path[i + 1], path[i + 2], path[i + 3])) {
clearThroughout = false; break;
}
}
check('the path stays legally clear of the obstacle throughout', clearThroughout);
let arrived1 = false;
for (let i = 0; i < 20 * HZ && !arrived1; i++) {
L.tick(st, pr);
if (Math.hypot(tank.x - destX, tank.y - destY) < 40) arrived1 = true;
}
check('the tank actually arrives after detouring around the obstacle', arrived1);
// A corridor exactly one tile wide — open ground above and below a 3-tile-thick wall,
// connected only by a single-tile gap — must still be usable; exact-fit clearance is the
// ONLY mode now, so this is no longer a fallback path, just ordinary routing. Padded out to
// 15x11 (cols 9+ / rows 7+ are pure dead space) for the same real-distance reason as above.
const W2 = 15, H2 = 11;
const rows2 = Array.from({ length: H2 }, () => '.'.repeat(W2));
for (const y of [2, 3, 4]) rows2[y] = `####.####${'.'.repeat(W2 - 9)}`;
const corridorMap = mkMap(rows2);
const st2 = L.createMatch(pr, { seed: 62, map: corridorMap, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
st2.over = null; for (const a of st2.armies) a.alive = true;
L.spawnUnit(st2, pr, 1, 'infantry', 14 * ts + ts / 2, 10 * ts + ts / 2);
const tank2 = L.spawnUnit(st2, pr, 0, 'tank', 4 * ts + ts / 2, 1 * ts + ts / 2);
const goalX = 4 * ts + ts / 2, goalY = 6 * ts + ts / 2;
const r2 = L.issueOrder(st2, pr, { army: 0, unitIds: [tank2.id], order: { type: 'move', x: goalX, y: goalY } });
check('move order accepted through the tight corridor', r2.ok, r2.error);
let arrived = false;
for (let i = 0; i < 20 * HZ && !arrived; i++) {
L.tick(st2, pr);
if (Math.hypot(tank2.x - goalX, tank2.y - goalY) < 40) arrived = true;
}
check('a unit still threads a corridor exactly its own width', arrived);
}
// ---------------------------------------------------------------------------
section('4b. Order queueing (CTRL)');
// ---------------------------------------------------------------------------
@ -1099,6 +1187,132 @@ section('5. Movement, separation and size classes');
check('all positions finite', st.entities.every((e) => Number.isFinite(e.x) && Number.isFinite(e.y)));
}
// ---------------------------------------------------------------------------
section('5b. Obstacle collision — smooth push, not a teleport');
// ---------------------------------------------------------------------------
{
// pushOutOfObstacles (TALogic.js's stepSeparation) replaced a hard "snap the unit to the
// nearest clear tile centre" correction with a circle-vs-blocked-square push proportional to
// overlap. Both properties below are what actually stops a unit "banging" against a building:
// a bounded per-tick nudge instead of a teleport, and a stable settle instead of oscillation.
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no commander
const pr = compileRules(raw);
const ts = pr.constants.tileSize;
const mk = (rows) => {
const w = rows[0].length, h = rows.length;
const terrain = new Uint8Array(w * h);
const wall = pr.terrainByCh['^'].index, open = pr.terrainByCh['.'].index;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open;
return { w, h, terrain, starts: [], theme: pr.skirmish.defaults.theme };
};
// A single 2x2 obstacle with open ground on every side. Padded out to 16x12 (rows/cols 8+ are
// pure dead space, built programmatically so the width can't drift out of sync by a
// hand-miscounted row) purely to give the army-1 decoy below genuine distance — everything
// this fixture actually measures still lives in the original 0-9,0-7 area.
const W0 = 16, H0 = 12;
const rows0 = Array.from({ length: H0 }, () => '.'.repeat(W0));
rows0[3] = `${rows0[3].slice(0, 4)}##${rows0[3].slice(6)}`;
rows0[4] = `${rows0[4].slice(0, 4)}##${rows0[4].slice(6)}`;
const map = mk(rows0);
const st = L.createMatch(pr, { seed: 63, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
st.over = null; for (const a of st.armies) a.alive = true;
// Army 1 needs SOMETHING alive or checkResult ends the match on tick one (it's "wiped", not
// just commander-less) and every tick after that silently no-ops. Genuinely far — not just a
// different corner of the SAME small map, which the mover's own destination below turned out
// to be close enough to for the tank's gun to reach and kill it, ending the match early. That
// failure mode is exactly why this needs to be measured in real px, not "looks like a corner".
L.spawnUnit(st, pr, 1, 'infantry', 15 * ts + ts / 2, 11 * ts + ts / 2);
const wallLeft = 4 * ts, wallTop = 3 * ts, wallRight = 6 * ts, wallBottom = 5 * ts;
const tank = L.spawnUnit(st, pr, 0, 'tank', 0, 0);
// Centre the unit 6px inside the obstacle's left edge, vertically centred on it.
tank.x = wallLeft - tank.radius + 6;
tank.y = (wallTop + wallBottom) / 2;
const startX = tank.x, startY = tank.y;
L.tick(st, pr);
const movedFirstTick = Math.hypot(tank.x - startX, tank.y - startY);
check('a single tick nudges an overlapping unit rather than teleporting it',
movedFirstTick > 0 && movedFirstTick < 15, `${movedFirstTick.toFixed(1)}px`);
for (let i = 0; i < 60; i++) L.tick(st, pr);
const nearestX = Math.max(wallLeft, Math.min(tank.x, wallRight));
const nearestY = Math.max(wallTop, Math.min(tank.y, wallBottom));
const settledDist = Math.hypot(tank.x - nearestX, tank.y - nearestY);
check('it settles clear of the obstacle rather than staying embedded',
settledDist >= tank.radius - 1, `${settledDist.toFixed(1)}px vs radius ${tank.radius}`);
const beforeStable = { x: tank.x, y: tank.y };
for (let i = 0; i < 20; i++) L.tick(st, pr);
const drift = Math.hypot(tank.x - beforeStable.x, tank.y - beforeStable.y);
check('a settled unit stops moving instead of bouncing off the obstacle', drift < 1,
`${drift.toFixed(2)}px over 20 ticks`);
// The scenario this was actually fixed for: order a unit to the far side of an obstacle and
// confirm it gets there instead of grinding against a corner forever.
const mover = L.spawnUnit(st, pr, 0, 'tank', 1 * ts, 1 * ts);
const destX = 9 * ts, destY = 7 * ts;
const r = L.issueOrder(st, pr, { army: 0, unitIds: [mover.id], order: { type: 'move', x: destX, y: destY } });
check('move order accepted for the routing fixture', r.ok, r.error);
let reached = false;
for (let i = 0; i < 30 * HZ && !reached; i++) {
L.tick(st, pr);
if (Math.hypot(mover.x - destX, mover.y - destY) < 40) reached = true;
}
check('a unit routed past an obstacle actually arrives', reached);
check('it is never flagged stuck long enough to give up',
mover.stuckTicks < pr.constants.stuckGiveUpSec * HZ);
}
// ---------------------------------------------------------------------------
section("5c. Obstacle collision — a unit wider than one tile doesn't freeze beside a building");
// ---------------------------------------------------------------------------
{
// pushOutOfObstacles's own-tile clearance check used to read raw nav.clearance[mc] instead of
// the dilated fitField(nav, mc, need). clearance[i] is anchored top-left ("largest passable
// square STARTING at i"), so for a need=1 unit (radius <= tileSize/2, every unit before the
// Megatank) that raw read happens to agree with fitField everywhere — but for a need=2 unit
// (radius > tileSize/2) it reads "not clear" on the bottom/right three tiles of any open 2x2
// block even though the unit fits fine there, firing the BFS-teleport rescue on nearly every
// tick next to any building and fighting stepMovement's real progress hard enough to freeze
// the unit in place (still turning, since heading tracks the path waypoint independently of
// whether the unit actually gets anywhere) — exactly what Brian reported for the Megatank.
// The Megatank is the only unit in the game with radius > tileSize/2 (33 vs 64/2=32), so it is
// the only fixture that can actually exercise this path; a `tank` (radius ~26) never would.
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
raw.constants.eliminateWhenUnrecoverable = false;
const pr = compileRules(raw);
const ts = pr.constants.tileSize;
check('fixture assumption: megatank radius exceeds half a tile (the need=2 trigger)',
pr.unitById.megatank.radius > ts / 2, `radius ${pr.unitById.megatank.radius} vs ${ts / 2}`);
const W = 20, H = 14;
const wall = pr.terrainByCh['^'].index, open = pr.terrainByCh['.'].index;
const terrain = new Uint8Array(W * H).fill(open);
for (let y = 5; y <= 6; y++) for (let x = 5; x <= 10; x++) terrain[y * W + x] = wall;
const map = { w: W, h: H, terrain, starts: [], theme: pr.skirmish.defaults.theme };
const st = L.createMatch(pr, { seed: 64, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
st.over = null; for (const a of st.armies) a.alive = true;
L.spawnUnit(st, pr, 1, 'infantry', 18 * ts + ts / 2, 12 * ts + ts / 2);
const mt = L.spawnUnit(st, pr, 0, 'megatank', 0, 0);
mt.x = 5 * ts - mt.radius + 4; // right beside the wall's left edge, on open ground
mt.y = 5.5 * ts;
const destX = 12 * ts, destY = 5.5 * ts;
const r = L.issueOrder(st, pr, { army: 0, unitIds: [mt.id], order: { type: 'move', x: destX, y: destY } });
check('move order accepted for the megatank fixture', r.ok, r.error);
let arrived = false;
for (let i = 0; i < 60 * HZ && !arrived; i++) {
L.tick(st, pr);
if (Math.hypot(mt.x - destX, mt.y - destY) < 40) arrived = true;
}
check('a megatank beside a building actually arrives instead of freezing in place', arrived);
check('it is never flagged stuck long enough to give up',
mt.stuckTicks < pr.constants.stuckGiveUpSec * HZ);
}
// ---------------------------------------------------------------------------
section('6. Combat');
// ---------------------------------------------------------------------------