From 347957bd846de06b758a1fd50d20d99370a79828 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 16 Aug 2026 18:59:30 -0600 Subject: [PATCH] TAL: smooth obstacle collision push + in-place building upgrades (upgradesFrom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- data/totalannihilation-campaign.json | 194 +++++++++++----------- src/games/totalannihilation/TALogic.js | 130 +++++++++++++-- tools/genTACampaign.js | 13 +- tools/verifyTotalAnnihilation.js | 214 +++++++++++++++++++++++++ 4 files changed, 431 insertions(+), 120 deletions(-) diff --git a/data/totalannihilation-campaign.json b/data/totalannihilation-campaign.json index 338ecaf..695c5ee 100644 --- a/data/totalannihilation-campaign.json +++ b/data/totalannihilation-campaign.json @@ -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": [ { diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index cdd32d9..6a6ea1d 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -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; diff --git a/tools/genTACampaign.js b/tools/genTACampaign.js index e51975e..63dfe12 100644 --- a/tools/genTACampaign.js +++ b/tools/genTACampaign.js @@ -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: [ diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index 4b0c3e2..a1b399f 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -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'); // ---------------------------------------------------------------------------