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:
parent
3c7184564a
commit
347957bd84
|
|
@ -787,7 +787,7 @@
|
||||||
"id": "m06",
|
"id": "m06",
|
||||||
"name": "Annihilation",
|
"name": "Annihilation",
|
||||||
"theme": "snowfields",
|
"theme": "snowfields",
|
||||||
"seed": 80808,
|
"seed": 90123,
|
||||||
"playerArmy": "arm",
|
"playerArmy": "arm",
|
||||||
"playerCommander": "vance",
|
"playerCommander": "vance",
|
||||||
"enemies": [
|
"enemies": [
|
||||||
|
|
@ -835,102 +835,102 @@
|
||||||
"h": 96,
|
"h": 96,
|
||||||
"theme": "snowfields",
|
"theme": "snowfields",
|
||||||
"rows": [
|
"rows": [
|
||||||
"~~~r....r...rrrrrr..........r.~~~~~~...............^^^^^^^^^^^^^^..................r....r..^^^^^",
|
"^^^^.......~~~.....r..r.....r....~~.....^^^^^^^^^^^........rr...........^^^^^^^^^^^^^^^^^^^^^^^^",
|
||||||
"~~~r..........r....MM.........~~~~~~...............^^^^^^^^^^^^^...........................^^^^^",
|
"^^^^.......~~~..................~~~.....^^^^^^^^^^^......................^^^^^^^^^^^^^^^^^^^^^^^",
|
||||||
"~~~...............rMM.........~~~~~r................^^^^^^^^^^^............................^^^^^",
|
"^^^^.......~~r.................~~~~......^^^^^^^^^.....rr................^^^^^^^^^^^^^^^^^^^^^^^",
|
||||||
"~~~...............rr..............r...................^^^^^^............................rr.^^^^^",
|
"^^^^..MM.....................r~~~~~~.....^^^^^^^^^.....rr..................^^^^^^^^^^^^^^^^^^^^^",
|
||||||
"rr................rrrr....................................r..........rr.................^^^^^^^^",
|
"^^^^..MM......MM............r~~~~~~~r....^^^^^^^^^rrr.......................^^^^^^^^^^^^..^^^^^^",
|
||||||
"...................rrrr.r............................................rr................^^^^^^^^^",
|
"^^^^..........MM.............r~~~~~rr.....^^^^^^^^..rr........................r..............r..",
|
||||||
"...................rrrrrrrr............................................................^^^^^^^^^",
|
"^^^^..........................rrrr.rr......^^^^.....rr..........................................",
|
||||||
"....................r...rr..................r...........................................^^^^^^rr",
|
"^^^............................r...............................r~~~~~~~~........................",
|
||||||
"r.....MM...................................r^^................rr........................^^^^^^..",
|
"^^^.....................~~.....................................~~~~~~~~~~~~~~...................",
|
||||||
"......MM.............^^^..................^^^^^............rrrrrrrrr...................^^^^^^^^.",
|
"^^^....................~~~.......rr............................~~~~~~~~~~~~~~~~~~~~~~~~~~~~rr...",
|
||||||
"..MM................^^^^^^.............rr^^^^^^^...........rrrrrrrrr.................r^^^^^^^^^^",
|
"^^.....................~~~~......rrr............................~~~~~~~~~~~~~~~~~~~~~~~..rrrr...",
|
||||||
"..MM...............^^^^^^^...rr........r^^^^^^^^^............r....^^r................^^^^^^^^^^^",
|
"^^^..MM...........~~~~~~~~~...rrrrr................^^^^^^^r......~~~~~~~~~~~.............rrrr...",
|
||||||
"...................^^^^^^^..rrr..rr....r^^^^^^^^^.................^^^................^^^^^^^^^^^",
|
"^^^..MM...........~~~~~~~~~rrrrrrrr...............^^^^^^^^^......r~~~~~rrr................rr....",
|
||||||
"..................r^^^^^^^rrrrrr.rr....r^^^^^^^^^..................^........rr.......^^^^^^^^^^^",
|
"^^^...............~~..~~~~~rr.....................^^^^^^^^..rr....~~~~~.r.......................",
|
||||||
"..................r^^^^^^...rrrr..........^^^^^^rr.....rrrrrrr..............rrrrr.....^^^^^^^^^.",
|
"............................r.......................^^^^^^..rr....~~~~.rrr......................",
|
||||||
"..................^^^^^^....rrrrr................rrrrr~~~~~rrr..............rrrrr........r...r..",
|
"............................rr........................^^^...rr....~~~~rrrr......................",
|
||||||
".................^^^^^^r...rrr.rr..~.............rrr~~~~~~~~~.............................rrr...",
|
".............................r....................................~~~~..........................",
|
||||||
"~..............^^^^^.^.....rr....~~~~.rrr........rrr~~~~~~~~~..............................r....",
|
".............................rrr..................................~~~...........................",
|
||||||
"~~.........r^^^^^^..............~~~~..rrr.............~~~~~~....................................",
|
"............................rrrr..................rrr.............~~...........................r",
|
||||||
"~~MM.......^^^^^...............~~~~........................rrrrrr...............................",
|
"~~MMrr.....rr................rr...................rrr..............r............................",
|
||||||
"~~MM.......^^^^^..............~~~~~........................rrrrrr...............................",
|
"~~MMrrrrrrrrrrr.........................rr......................rrr.............................",
|
||||||
"~~~.......r^^^^^.............~~~~~~.........................rrrr................................",
|
"~~~rrrrrrrrr^^^r..rr...................rrr......................rrr.............................",
|
||||||
"~~........^^^^^^^.............~~~~~.............................................................",
|
"~~~rrrrrrrrr^^rr..rr....................rr................rr............................rr......",
|
||||||
"..........^^^^^^^^..............~~~.............................................rrr.....r^......",
|
"~~~rr..rrrrrrrrr.......................................rrrrr........rr...............rrrrr.....r",
|
||||||
".........r^^^^^^^^................r.............................................rrr....^^^^.....",
|
"~~.....rr..r....r......................................rrrrr...rr..rrr.......^^^.....rrr........",
|
||||||
".........r^^^^^^^^rrrr.....r....................................................rrrr...^^^^^....",
|
"~~.....rr......rr......................................rrrr....rr...r........^^^......rr........",
|
||||||
"r..........^^^^^^^rrrr....rrr......................................................rr..^^^^^....",
|
"~~.....................................................rrrr....r....rr...............rrr........",
|
||||||
"r.............rrr....rrrrr.rr......................................................rrrrr^^^.....",
|
"................................................................~~~~~~..............rrr.........",
|
||||||
".rrrr........rrrr.......^^..r.......................................................rr..........",
|
".................................~.......~~~~~~~r............~~~~~~~~~~~............r...........",
|
||||||
"..rrrr.......rr........^^^^^^.........................r...r...............................rrr...",
|
"...............rr...rr..........~~~...~~~~~~~~~~~~..........~~~~~~~~~~~~..........rr...........r",
|
||||||
"^^.rrr............rr..r^^^^^^r.......................^^^^^^^............................^^^^^^^^",
|
"...............rr..rrr...........~~...~~~~~~~~~~~~...........~~~~~~~~~~~........rrr.............",
|
||||||
"^^.rrrr...........rrrr.^^^^^.......................^^^^^^^^^^^^.r.......................^^^^^^^^",
|
".rr............rr...rr.........r........~~~~~~~~~.............~~~~~~~~~.........rr..............",
|
||||||
"^^rrrrr........rrrr...^^^^^.....^^^^^.............^^^^^^^^^^^^^^^rr....................^^^^^^^^^",
|
"rrr............rrr....rrr.....rrr.........r~~~~................~~~~~~~.......^^^..............rr",
|
||||||
"^^rrr..........rrr^...^^^^.....r^^^^^.............^^^^^^^^^^^^^^^^^....................^^^^^^^^^",
|
"r.....................rrrr..rr..r...........~~..................~~~~~~......^^^^^^...r.rr....rr.",
|
||||||
"^^.rr..........rrr....^^^r.....r^^^^...............^^^^^^^^^^^^^........................^^^^^^^^",
|
"r......................r.rrrrr..r...............................~~~~~~.......^^^^^..rrrrr....rr.",
|
||||||
"^..rr..rr...............r.......^^..................^^^^^^^^^..............rrr..................",
|
"...rr..........................rr......................rr........~~~~........^^^^....rrr........",
|
||||||
"....r.rrr...............r...^^^^.....................r^^^^................rrrr~.................",
|
"...rr..........................rr.....................rrrr........r...................r.........",
|
||||||
".....rrrr..............rrr..^^^^.....................rr...................rrrr~.................",
|
".......................r...............................rrr.................r.........rrr........",
|
||||||
"r......rr...............r...^^^^.....................rr.........................................",
|
".......................r~~.............................rrr.......rr........rrrr.....rrrr........",
|
||||||
".r...........................^^^^^^...r...........rrr........................................rr.",
|
".....................~~~~~~......rr..............................rr........rrrr....rrrr.........",
|
||||||
".rr..........................^^^^^^^rrrr..........rrr.......................................rrr.",
|
"r...................~~~~~~~~.....rr..............................rrr.......rrr......rr..........",
|
||||||
"..r...................~~~....^^^^^^^^^^r.r..........r.......................................rrr.",
|
"r..................~~~~~~~~~~...rrr........^^^^...................rr........rr......rr..........",
|
||||||
"~~~....rrr..........~~~~~~...^^^^^^^^^^^^^^^^^^.............................................rrrr",
|
"~~.................~~~~~~~~~~~..r.........^^^^^^......rrr........^^^^^.............rrrr..^......",
|
||||||
"~~~~...rrrr........~~~~~~~....^^^^^^^^^^^^^^^^^^..........rr..................................~~",
|
"~~~...............~~~~~~~~~~~~~r.........^^^^^^^^....rrrr......r^^^^^^^.............rrrr^^^.....",
|
||||||
"~~~~...rrrr........~~~~~~~....^^^^^^^^^^^^^^^^^^^.........rr......~~~........................~~~",
|
"~~~~..............~~~~~~~~~~~~~..........^^^^^^^^r...r.........^^^^^^^^..............rrr^^^.....",
|
||||||
"~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^^^..........r......~~~........................~~~",
|
"~~~~..............~~~~~~~~~~~~~..........^^^^^^^^..............^^^^^^^^..............rrr^^^.....",
|
||||||
"~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^^.r........rr......~~..........................~~",
|
"~~~~..............~~~~~~~~~~~~r..........^^^^^^^^...............^^^^^^^..............rrr^^^.....",
|
||||||
"~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^..rrrrr....rr..r...~~.....r....................~~",
|
"~~~r....rr........~~~~~~~~~~~.r.........^^^^^^^^^.....rr........r^^^^^...............rrr........",
|
||||||
"~~....................r.....~~...r..rr....rrrrr..^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
|
"........rrr...............^^^^^r........rr.....^^^^^^^^^.........r.~~~~~~~~~~~........rr....r~~~",
|
||||||
"~~..........................~~......rr........r.^^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
|
".....^^^rrr..............^^^^^^^...............^^^^^^^^..........r~~~~~~~~~~~~..............~~~~",
|
||||||
"~~~........................~~~......r..........^^^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
|
".....^^^rrr..............^^^^^^^^..............^^^^^^^^..........~~~~~~~~~~~~~..............~~~~",
|
||||||
"~~~........................~~~......rr.........^^^^^^^^^^^^^^^^^^^....~~~~~~~........rrrr...~~~~",
|
".....^^^rrr..............^^^^^^^^.........r...r^^^^^^^^..........~~~~~~~~~~~~~..............~~~~",
|
||||||
"~~..................................rr..........^^^^^^^^^^^^^^^^^^....~~~~~~~........rrrr...~~~~",
|
".....^^^rrrr.............^^^^^^^r......rrrr....^^^^^^^^.........r~~~~~~~~~~~~~...............~~~",
|
||||||
"rrrr.............................................^^^^^^^^^^^^^^^^^^...~~~~~~..........rrr....~~~",
|
"......^..rrrr.............^^^^^........rrr......^^^^^^.........r..~~~~~~~~~~~.................~~",
|
||||||
".rrr.......................................r..........r.r^^^^^^^^^^....~~~...................r..",
|
"..........rr......rr........rr...................^^^^........rrr...~~~~~~~~~~..................r",
|
||||||
".rrr.......................................rrr..........rrrr^^^^^^^..........................rr.",
|
"..........rr......rrr.......rrr..............................rr.....~~~~~~~~...................r",
|
||||||
".rr........................................rrr...........r...^^^^^^...........................r.",
|
".........rrrr....rrrr........rr..............................rr......~~~~~~.....................",
|
||||||
".........................................rr.....................^^^^...r...............rr......r",
|
"........rrrr.....rrrr........rr.......rrr.............................~~r.......................",
|
||||||
".................~rrrr...................rr.....................^^^^..rrr..............rrrr.....",
|
"........rrr.........r.................rrr...............................r.......................",
|
||||||
".................~rrrr................^^^^r.....................^^^^...r...............rrr.r....",
|
".........r...................r........rrrr.....................rr..........................rr...",
|
||||||
"..................rrr..............^^^^^^^^^..................^^.......r...............rr..rr..^",
|
"........rrr....^^^^........~~~~........rr......................rr..........................rr...",
|
||||||
"^^^^^^^^........................^^^^^^^^^^^^^...............^^^^r.....r^^^....rrr..........rr.^^",
|
".rr....rrrrr..^^^^^.......~~~~~~...............................r..rrrrr.r......................r",
|
||||||
"^^^^^^^^^....................^^^^^^^^^^^^^^^^^.............^^^^^r.....^^^^...^rrr..........rrr^^",
|
".rr....rr.r...^^^^^^......~~~~~~..................~~...........r..rr..rrrr.....................r",
|
||||||
"^^^^^^^^^....................rr^^^^^^^^^^^^^^^.............^^^^^.....^^^^^...rrrr........rrrrr^^",
|
"rr..............^^^.......~~~~~~~................~~~~r.........rrr.....rrr....rrr............rrr",
|
||||||
"^^^^^^^^.......................r.^^^^^^^^^^^^.......................^^^^^.rrrr...........rrrr.^^",
|
"..............rr.........~~~~~~~~~.............~~~~~~~~~........r.........rr...rr............rr.",
|
||||||
"^^^^^^^^............................^^^^^^^.......................r^^^^^^r..rr............rrr.^^",
|
".............rrr........~~~~~~~~~~~...........~~~~~~~~~~~~...~~...........rrr..rr...............",
|
||||||
"...rrr...............................r...r.........................^^^^^^........rr.......rrrr..",
|
"r...........rr..........~~~~~~~~~~~~..........~~~~~~~~~~~~...~~~..........rr...rr...............",
|
||||||
"..........rr.......................................................r..^^.......rrrr........rrrr.",
|
"...........r............~~~~~~~~~~~............r~~~~~~~.......~.................................",
|
||||||
".....^^^rrrrr......................................................rr.rrrrr....rrr.............r",
|
".........rrr..............~~~~~~................................................................",
|
||||||
"....^^^^^..rr......................................................rrr....rrrr^^^^^^^..........r",
|
"........rrr...............rr....r....rrrr.....................................................~~",
|
||||||
"....^^^^^...rrrr....................................................r.....rrrr^^^^^^^^r.........",
|
"........rr......^^^........r...rr....rrrr......................................rr......rr.....~~",
|
||||||
".....^^^^....rrr.............................................r................^^^^^^^^r.........",
|
"........rrr.....^^^.......rrr..rr...rrrrr......................................r....r..rr.....~~",
|
||||||
"......^r.....rrr.............................................~~~..............^^^^^^^^..........",
|
"r.....rrrrr...............rr........rrrrr.......................................rrrrrrrrr..rr~~~",
|
||||||
".............................................................~~~~~.............^^^^^^^........~~",
|
"......rr............................rr................rr....................rr..rr^^rrrrrrrrr~~~",
|
||||||
"................................rrrr.........................~~~~~~.............^^^^^r.......~~~",
|
".............................rrr......................rrr...................rr..r^^^rrrrrrrrr~~~",
|
||||||
"...............................rrrrrr........................~~~~~..............^^^^^.......MM~~",
|
".............................rrr......................rr.........................rrrrrrrrrrrMM~~",
|
||||||
"...............................rrrrrr........................~~~~...............^^^^^.......MM~~",
|
"............................r..............rrr...................rr................rr.....rrMM~~",
|
||||||
"....................................~~~~~~.............rrr..~~~~..............^^^^^^r.........~~",
|
"r...........................~~.............rrr..................rrrr............................",
|
||||||
"....r..............................~~~~~~~~~rrr........rrr.~~~~....rr.....^.^^^^^..............~",
|
"...........................~~~..................................rrr.............................",
|
||||||
"...rrr.............................~~~~~~~~~rrr.............~..rr.rrr...r^^^^^^.................",
|
"..........................~~~~....................................r.............................",
|
||||||
"..r...r........rrrrr..............rrr~~~~~rrrrr................rrrrr....^^^^^^..................",
|
"......................rrrr~~~~....rr...^^^........................rr............................",
|
||||||
".^^^^^^^^^.....rrrrr..............rrrrrrr.....rr^^^^^^..........rrrr...^^^^^^r..................",
|
"......................rrr.~~~~....rr..^^^^^^.......................r............................",
|
||||||
"^^^^^^^^^^^.......rr........^..................^^^^^^^^^r....rr.rrrrrr^^^^^^^r..................",
|
".......................r.~~~~~....rr..^^^^^^^^.....................rr~~~~~..~~...............^^^",
|
||||||
"^^^^^^^^^^^................^^^.................^^^^^^^^^r....rr..rrr..^^^^^^^...................",
|
"....rr................rrr~~~~~r......^^^^^^^^^...............rrrrrrrr~~~~~~~~~...........MM..^^^",
|
||||||
"^^^^^^^^^^^................r^^....r............^^^^^^^^^r........rr...^^^^^^^...............MM..",
|
"...rrrr.............~~~~~~~~~~~......r^^^^^^^................rrrrr...~~~~~~~~~...........MM..^^^",
|
||||||
"^^^^^^^^^^r.................rrrrrrrrr...........^^^^^^^rr.............^^^^^^................MM..",
|
"...rrrr..~~~~~~~~~~~~~~~~~~~~~~~............................rrr......~~~~.....................^^",
|
||||||
".^^^^^^^^...................rrrrrrrrr............^^^^^..................^^^.............MM......",
|
"...rr~~~~~~~~~~~~~~~~~~~~~~~~~~~~............................rr.......~~~....................^^^",
|
||||||
"..^^^^^^........................rr................^^r...................................MM.....r",
|
"...................~~~~~~~~~~~~~~.....................................~~.....................^^^",
|
||||||
"rr^^^^^^...........................................r..................rr...r....................",
|
"........................~~~~~~~~r...............................r............................^^^",
|
||||||
"^^^^^^^^^............................................................rrrrrrrr...................",
|
"..........................................rr.....^^^^......rr.rrrr..........................^^^^",
|
||||||
"^^^^^^^^^................rr............................................r.rrrr...................",
|
"..r..............r........................rr..^^^^^^^^.....rr~~~~~r.............MM..........^^^^",
|
||||||
"^^^^^^^^.................rr..........r....................................rrrr................rr",
|
"^^^^^^..^^^^^^^^^^^^.......................rrr^^^^^^^^^....r~~~~~~~r............MM......MM..^^^^",
|
||||||
"^^^^^.rr............................^^^^^^...................r..............rr...............~~~",
|
"^^^^^^^^^^^^^^^^^^^^^..................rr.....^^^^^^^^^.....~~~~~~r.....................MM..^^^^",
|
||||||
"^^^^^............................^^^^^^^^^^^................r~~~~~.........MMr...............~~~",
|
"^^^^^^^^^^^^^^^^^^^^^^^................rr.....^^^^^^^^^......~~~~.................r~~.......^^^^",
|
||||||
"^^^^^...........................^^^^^^^^^^^^^...............~~~~~~.........MM....r..........r~~~",
|
"^^^^^^^^^^^^^^^^^^^^^^^......................^^^^^^^^^^^.....~~~..................~~~.......^^^^",
|
||||||
"^^^^^..r....r..................^^^^^^^^^^^^^^...............~~~~~~.r..........rrrrrr...r....r~~~"
|
"^^^^^^^^^^^^^^^^^^^^^^^^...........rr........^^^^^^^^^^^.....~~....r.....r..r.....~~~.......^^^^"
|
||||||
],
|
],
|
||||||
"starts": [
|
"starts": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNav, stampFootprint, clearanceFor, findPath, smoothPath, formationSlots,
|
createNav, stampFootprint, clearanceFor, findPath, smoothPath, formationSlots,
|
||||||
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
|
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY, fitField,
|
||||||
SpatialHash, segmentClear,
|
SpatialHash, segmentClear,
|
||||||
} from './TANav.js';
|
} from './TANav.js';
|
||||||
import { canEngage, weaponHitsDomain } from './TARules.js';
|
import { canEngage, weaponHitsDomain } from './TARules.js';
|
||||||
|
|
@ -791,14 +791,22 @@ function servicePathQueue(state, rules) {
|
||||||
const nav = state.nav;
|
const nav = state.nav;
|
||||||
const need = clearanceFor(e.radius, state.tileSize);
|
const need = clearanceFor(e.radius, state.tileSize);
|
||||||
const mc = def.moveClass;
|
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 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);
|
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; }
|
if (sIdx < 0 || gIdx < 0) { e.noPath = true; e.path = null; continue; }
|
||||||
|
|
||||||
const tiles = findPath(nav, mc, need, sIdx, gIdx);
|
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; }
|
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.path = smoothPath(nav, mc, need, tiles, e.destX, e.destY);
|
||||||
e.pathIdx = 0;
|
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 nav = state.nav;
|
||||||
const ts = state.tileSize;
|
const ts = state.tileSize;
|
||||||
for (const e of movers) {
|
for (const e of movers) {
|
||||||
const def = defOf(rules, e);
|
const def = defOf(rules, e);
|
||||||
// Aircraft are only bounded by the map edge — nothing on the grid blocks them, and the
|
// 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) {
|
if (e.isAir) {
|
||||||
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
|
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));
|
e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const need = clearanceFor(e.radius, ts);
|
pushOutOfObstacles(nav, ts, e, def, stiff);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
|
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));
|
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) {
|
function shove(state, rules, e, nx, ny) {
|
||||||
e.blockedTicks = 0;
|
e.blockedTicks = 0;
|
||||||
const ts = state.tileSize;
|
const ts = state.tileSize;
|
||||||
|
|
|
||||||
|
|
@ -92,12 +92,13 @@ const MISSIONS = [
|
||||||
defeat: 'The push came faster than the plant did.',
|
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
|
// Re-seeded twice. First: 63541 (fell to 0/8) -> 80808 (8/8) when static defence became a
|
||||||
// became a skill-3+ behaviour with a PROACTIVE first tower (TAAI.urgentDefence no longer
|
// skill-3+ behaviour with a PROACTIVE first tower. Second: 80808 (fell to 0/8) -> 90123
|
||||||
// waits for a raid to build the first one). KLAXON here is skill 5, well past that gate,
|
// (8/8) when the movement/collision rework (smooth obstacle push replacing hard tile-snap,
|
||||||
// so it now stands up a tower before the player-bot's rush can land — 90123 and 56789 also
|
// pathfinding routing at exact-fit clearance instead of padded) changed this specific map's
|
||||||
// scored 8/8 in the same sweep if this needs revisiting again.
|
// early economy enough that the player-bot's mass income never recovered — a pre-vetted
|
||||||
id: 'm06', name: 'Annihilation', seed: 80808, size: 'medium', theme: 'snowfields', symmetry: 'rotational',
|
// 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 },
|
enemy: { commander: 'klaxon', skill: 5, aggression: 0.85 },
|
||||||
startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }],
|
startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }],
|
||||||
prebuilt: [
|
prebuilt: [
|
||||||
|
|
|
||||||
|
|
@ -826,6 +826,94 @@ section('4. Pathfinding');
|
||||||
segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts));
|
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)');
|
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)));
|
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');
|
section('6. Combat');
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue