From 625ced5649ed512f674c2a0cf7b70f327e1c765c Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 17 Aug 2026 21:15:32 -0600 Subject: [PATCH] TA AI Updates --- src/games/totalannihilation/TAAI.js | 201 +++++++++++++++++++++++++--- tools/verifyTotalAnnihilation.js | 11 ++ 2 files changed, 197 insertions(+), 15 deletions(-) diff --git a/src/games/totalannihilation/TAAI.js b/src/games/totalannihilation/TAAI.js index 3efa278..d42ceba 100644 --- a/src/games/totalannihilation/TAAI.js +++ b/src/games/totalannihilation/TAAI.js @@ -26,6 +26,12 @@ const COMPOSITION = { // enemy owning one (applyCounters pushes it hard the moment enemy air is actually seen), // and the hover constructor is the same insurance policy as the tracked one. bomber: 0.44, fighter: 0.30, hoverconstructor: 0.10, + // Advanced Vehicle Plant roster — these only compete with each other once one is standing. + // Megatank is the AVP's answer to the Tank, so it gets a real share; Rocket Artillery is a + // specialist siege piece that commits hard to a single target and is fragile if caught + // without escort, so some but not a mainstay; Advanced Construction Vehicle is a + // replacement/backup builder like `constructor`, same low share. + megatank: 0.22, rocketartillery: 0.08, advancedconstructor: 0.04, }; function memFor(state, armyIdx) { @@ -181,24 +187,38 @@ function gatherOwn(ctx) { // --------------------------------------------------------------------------- function manageEconomyAndBase(ctx, mine) { - const { state, army, rules, mem, expansion } = ctx; - const builder = mine.builders[0]; - if (!builder) return; + const { rules, expansion } = ctx; + if (!mine.builders.length) return; - // Build orders QUEUE on the builder rather than replacing its current job, so the - // Commander rolls straight from one structure into the next with no idle gap. Issuing - // these unqueued caps every skill at one building in flight, which flattens the whole - // skill ladder — a skill-5 economy then looks identical to a skill-1 one. + // Build orders QUEUE on a builder rather than replacing its current job, so it rolls + // straight from one structure into the next with no idle gap. Issuing these unqueued caps + // every skill at one building in flight, which flattens the whole skill ladder — a skill-5 + // economy then looks identical to a skill-1 one. const maxQueued = Math.max(1, Math.round(1 + expansion * 3)); - const queued = builder.orders.reduce((n, o) => n + (o.type === 'build' ? 1 : 0), 0); - if (queued >= maxQueued) return; + const queuedOn = (b) => b.orders.reduce((n, o) => n + (o.type === 'build' ? 1 : 0), 0); + const available = mine.builders.filter((b) => queuedOn(b) < maxQueued); + if (!available.length) return; - const want = chooseBuilding(ctx, mine); + // The Commander cannot build an Advanced Vehicle Plant, and NEITHER the Commander nor a + // plain Constructor can build an Advanced Metal Generator or Nuclear Power Plant — only a + // Constructor/Hover Constructor and an Advanced Construction Vehicle respectively can. What + // the whole army wants next (`chooseBuilding`) has to be something SOME available builder can + // actually construct, or the AI just spins forever wanting a building nothing can start — + // this was silently happening (Advanced Vehicle Plant, Nuclear Power Plant) until caught by + // a build-repertoire soak showing zero of either across six full games. + const buildable = new Set(); + for (const b of available) for (const id of rules.defById[b.defId].builds ?? []) buildable.add(id); + + const want = chooseBuilding(ctx, mine, buildable); if (!want) return; + const builder = available.find((b) => (rules.defById[b.defId].builds ?? []).includes(want.id)); + if (!builder) return; + const spot = findPlacement(ctx, mine, want, builder, placementOptionsFor(ctx, want)); if (!spot) return; + const queued = queuedOn(builder); order(ctx, { army: ctx.armyIdx, unitIds: [builder.id], order: { type: 'build', defId: want.id, tx: spot.tx, ty: spot.ty }, @@ -206,7 +226,7 @@ function manageEconomyAndBase(ctx, mine) { }); } -function chooseBuilding(ctx, mine) { +function chooseBuilding(ctx, mine, buildable) { const { rules, army, mem, expansion, skill } = ctx; const n = (id) => (mine.byDef[id] ?? 0); const eGen = rules.buildingById.energygen; @@ -214,6 +234,9 @@ function chooseBuilding(ctx, mine) { const barracks = rules.buildingById.barracks; const plant = rules.buildingById.vehicleplant; const airfield = rules.buildingById.airfield; + const avp = rules.buildingById.advancedvehicleplant; + const advMGen = rules.buildingById.advancedmassgen; + const nuclear = rules.buildingById.nuclearplant; // Opening: two power, one mass, then a barracks so there's something to fight with. if (n('energygen') < 2) return eGen; @@ -262,6 +285,23 @@ function chooseBuilding(ctx, mine) { return airfield; } + // Exactly one Advanced Vehicle Plant, same tier and reasoning as the Airfield above — it is + // what unlocks the Megatank, the Advanced Construction Vehicle and the Rocket Artillery, + // none of which this AI can ever field without one. Deliberately the SAME numeric threshold + // as the Airfield's own rather than a stricter one — an earlier attempt required + // energygen>=5 and buildEff>0.55 and it never fired in six full games, because buildEff sags + // exactly as generator counts climb (the economy is feeding a bigger base, not sitting + // idle), so a stricter combined bar than the Airfield's own already-hard-won one is rarely + // met at all. Even matching it exactly wasn't enough on its own, though: since the Airfield + // check runs first and shares the identical bar, it claims the first qualifying tick and + // buildEff had usually sagged back under 0.5 by the time this check got its own turn — so an + // Airfield already standing (proof the economy cleared this bar once already) unlocks the + // AVP immediately, instead of waiting on a second independent crossing of the same window. + if (avp && buildable.has(avp.id) && n('advancedvehicleplant') < 1 && n('vehicleplant') >= 1 + && (n('airfield') >= 1 || (n('energygen') >= 4 && n('massgen') >= 3 && army.buildEff > 0.5))) { + return avp; + } + // Steady state: chase the energy:mass income RATIO that the unit roster actually costs. // A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources // symmetrically ends up capping out on energy while its factories starve for mass. @@ -269,16 +309,30 @@ function chooseBuilding(ctx, mine) { const ratio = netE / Math.max(0.5, army.mIncome); const TARGET_RATIO = 4.0; + // Counts BOTH tiers against the same cap — an upgraded generator still occupies one of the + // "how many of these do we want" slots, so the cap governs total generating structures + // rather than letting the AI stack a full cap's worth of each tier back to back. const cap = Math.round(3 + expansion * 7); - const roomE = n('energygen') < cap; + const roomE = n('energygen') + n('nuclearplant') < cap; // A Mass Generator is an energy CONSUMER. Building one without the headroom to power it // just throttles the ones already standing, so require spare energy before adding another. const upkeepPer = mGen.upkeep?.energy ?? 0; - const roomM = n('massgen') < cap && netE > upkeepPer * 1.6; + const roomM = n('massgen') + n('advancedmassgen') < cap && netE > upkeepPer * 1.6; + // A genuine stall wants the fast, cheap fix — a plain generator finishes sooner than its + // pricier upgraded form, which matters most exactly when the economy can least afford to + // wait, so this stays the base-tier building, not `upgradeOr`. if (army.stallM < 0.98 && roomM) return mGen; if (army.stallE < 0.98 && roomE) return eGen; + // Trade an existing base-tier generator for its advanced form once there's genuine spare + // energy, independent of `roomM`/`roomE` above — those only gate ADDING a new generator, but + // replacing one is worth it purely on energy headroom, room-cap or not. Without this an + // economy that reaches its generator cap simply stops growing mass/energy income at all + // instead of upgrading what it already has, which is the more common case for a mature base. + if (advMGen && buildable.has(advMGen.id) && wantsUpgrade(ctx, mine, mGen, advMGen)) return advMGen; + if (nuclear && buildable.has(nuclear.id) && wantsUpgrade(ctx, mine, eGen, nuclear)) return nuclear; + // Production capacity is grown ALONGSIDE the economy, sized to what the income can // actually feed. Saturating generators first (the obvious ordering) is why a big economy // used to win nothing: with only two factories the surplus just floated in storage, and an @@ -294,6 +348,9 @@ function chooseBuilding(ctx, mine) { const defence = steadyDefence(ctx, mine); if (defence) return defence; + // Plain generators here too — by this point the dedicated upgrade check above has already + // fired if an upgrade was actually wanted, so reaching here means it wasn't; these are purely + // about ADDING capacity, which `roomM`/`roomE` already gate. if (ratio > TARGET_RATIO && roomM) return mGen; if (ratio < TARGET_RATIO && roomE) return eGen; if (roomM) return mGen; @@ -302,6 +359,23 @@ function chooseBuilding(ctx, mine) { return null; } +/** + * Once there's a genuine baseline of the base-tier generator AND spare energy to run the + * upkeep, trade it for the upgraded form instead of stacking another base-tier one — an + * Advanced Metal Generator produces 4x a Mass Generator's mass from the same footprint, and a + * Nuclear Power Plant a similar step up over an Energy Generator, so growth past a small base + * count is much better spent there. `findPlacement` prefers landing the upgrade directly on + * top of an existing base-tier building (reclaiming half its cost) over claiming fresh ground. + */ +function wantsUpgrade(ctx, mine, base, advanced) { + const { army } = ctx; + const n = (id) => (mine.byDef[id] ?? 0); + if (!advanced || n(base.id) < 2) return false; + const netE = Math.max(0, army.eIncome - army.eUpkeep); + const upkeepAdv = advanced.upkeep?.energy ?? 0; + return netE > upkeepAdv * 1.6; +} + /** * Static defence is a skill-3-and-up behaviour, like the Airfield. * @@ -404,11 +478,21 @@ function factoryMassDrain(rules, def) { * a metal patch, which is what makes map control matter without a reclaim economy. */ function findPlacement(ctx, mine, def, builder, opts = {}) { - const { state, rules } = ctx; + const { state, rules, armyIdx } = ctx; const mem = ctx.mem; const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x); const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y); + // An upgrade building (Advanced Metal Generator over a Mass Generator, Nuclear Power Plant + // over an Energy Generator) prefers replacing an existing one of its own base type in place + // — reclaiming half its cost — over claiming fresh ground, exactly like a human player + // queuing the upgrade on top of what's already there. + if (def.upgradesFrom) { + const target = mine.buildings.find((b) => b.defId === def.upgradesFrom + && canPlaceAt(state, rules, b.tx, b.ty, def, armyIdx).ok); + if (target) return { tx: target.tx, ty: target.ty }; + } + if (def.terrainMultiplier) { const spot = findMetalSpot(ctx, def, bx, by); if (spot) return spot; @@ -432,7 +516,11 @@ function findPlacement(ctx, mine, def, builder, opts = {}) { const tx = bx + Math.round(Math.cos(a) * r); const ty = by + Math.round(Math.sin(a) * r); if (!spacedEnough(ctx, mine, tx, ty, def)) continue; - if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty }; + if (!canPlaceAt(state, rules, tx, ty, def).ok) continue; + // Reject a legal spot that would seal the base off from the rest of the map — see + // `keepsBaseOpen`. The spiral just keeps going and tries the next candidate. + if (!keepsBaseOpen(ctx, tx, ty, def)) continue; + return { tx, ty }; } } } @@ -466,12 +554,72 @@ function findMetalSpot(ctx, def, bx, by) { const d = Math.hypot(tx - bx, ty - by); if (d > 30 || d >= bestD) continue; if (!canPlaceAt(state, rules, tx, ty, def).ok) continue; + // Checked last, behind the cheap filters above, since it walks the nav grid — a metal + // patch tucked in a corridor is exactly the kind of spot that would wall the base in. + if (!keepsBaseOpen(ctx, tx, ty, def)) continue; best = { tx, ty }; bestD = d; } } return best; } +// How far past a placement candidate counts as genuinely "off base" for `keepsBaseOpen` — a +// little past `findPlacement`'s own widest spiral radius (22) and matching `findMetalSpot`'s +// own 30-tile metal-patch search radius, so a legitimate metal-patch generator at the edge of +// that range doesn't read as "trapped" just for being far from the base centre. +const ESCAPE_DIST_TILES = 30; +const ESCAPE_BFS_BUDGET = 3000; + +/** + * Cheap connectivity guard: would placing `def` at (tx,ty) cut the base off from the rest of + * the map? Floods outward from the base centre (4-connected, ignoring move-class/terrain cost + * — a deliberately conservative "is there ANY way out" check, not real pathfinding) treating + * the candidate footprint as an extra obstruction on top of whatever is already blocked, and + * succeeds as soon as it reaches open ground well past the base's own build radius. + * + * This is not a real min-cut — it's a bounded flood fill that gives up after `ESCAPE_BFS_BUDGET` + * tiles — but a base walls itself in one placement at a time, so catching the specific + * placement that would finish sealing it off is enough to prevent the trap without paying for + * anything more expensive on every building decision. + */ +function keepsBaseOpen(ctx, tx, ty, def) { + const { state, mem } = ctx; + if (!mem.baseSet) return true; // nothing established yet to protect + const nav = state.nav; + const fw = def.footprint.w, fh = def.footprint.h; + const blockedNow = (x, y) => { + if (x >= tx && x < tx + fw && y >= ty && y < ty + fh) return true; + return !!nav.blocked[y * nav.w + x]; + }; + + const bx = worldToTileX(nav, mem.baseX), by = worldToTileY(nav, mem.baseY); + if (blockedNow(bx, by)) return true; // base tile itself isn't open ground; not this check's job + + const seen = new Uint8Array(nav.w * nav.h); + // Each of up to ESCAPE_BFS_BUDGET dequeues can enqueue up to 4 neighbours, so the queue + // needs headroom for the whole frontier, not just the processed count. + const qcap = Math.min(nav.w * nav.h, ESCAPE_BFS_BUDGET * 4 + 8); + const qx = new Int32Array(qcap); + const qy = new Int32Array(qcap); + let head = 0, tail = 0; + qx[tail] = bx; qy[tail] = by; tail++; + seen[by * nav.w + bx] = 1; + + while (head < tail && head < ESCAPE_BFS_BUDGET) { + const x = qx[head], y = qy[head]; head++; + if (Math.hypot(x - bx, y - by) > ESCAPE_DIST_TILES) return true; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx, ny = y + dy; + if (nx < 0 || ny < 0 || nx >= nav.w || ny >= nav.h) continue; + const ni = ny * nav.w + nx; + if (seen[ni] || blockedNow(nx, ny)) continue; + seen[ni] = 1; + if (tail < qx.length) { qx[tail] = nx; qy[tail] = ny; tail++; } + } + } + return false; // exhausted the budget without escaping — this placement would trap the base +} + function spacedEnough(ctx, mine, tx, ty, def) { const gap = 1; for (const b of [...mine.buildings, ...mine.sites]) { @@ -514,11 +662,30 @@ function randomPick(ctx, builds) { } /** Pick whichever buildable unit is furthest below its share of the desired mix. */ +// Nothing else in the roster can build an Advanced Vehicle Plant or upgrade a generator +// except these two, so if either is ever going to happen at all, at least one of these has to +// actually get produced — not just weighted like an ordinary combat unit. Their COMPOSITION +// weight is deliberately kept low (they're a poor use of resources beyond that one job), but +// under the plain gap-based pick below a low, STATIC weight can get starved out indefinitely: +// combat losses keep refreshing everything else's gap back above it, while a unit that's +// simply never been built holds the same small gap forever and rarely wins the comparison. +// Measured directly: across 6 full skirmishes, Constructor was built zero times, which meant +// the Advanced Vehicle Plant — and everything behind it — was unreachable regardless of how +// well the economy was doing. +const BUILDER_UNITS = new Set(['constructor', 'advancedconstructor']); + function templatePick(ctx, mine, builds) { const { rules, skill, profile } = ctx; const weights = { ...COMPOSITION, ...(profile.composition ?? {}) }; if (skill >= 4) applyCounters(ctx, weights); + // Also checks every factory's pending queue, not just finished/under-construction entities + // — otherwise this could re-fire on the same factory's next queue slot before the first one + // it ordered ever shows up as a real entity, stacking up redundant Constructors. + const missingBuilder = builds.find((id) => BUILDER_UNITS.has(id) && !(mine.byDef[id] ?? 0) + && !mine.factories.some((f) => f.queue.some((q) => q.defId === id))); + if (missingBuilder) return missingBuilder; + const total = builds.reduce((s, id) => s + (mine.byDef[id] ?? 0), 0) + 1; let best = null, bestGap = -Infinity; for (const id of builds) { @@ -552,6 +719,10 @@ function applyCounters(ctx, weights) { weights.rockettank *= 1 + (structures > 2 ? 0.5 : 0); weights.sniper *= 1 + (1 - armourShare) * 0.5; weights.rockettrooper *= 1 + armourShare * 0.6; + // Megatank is the heavy-armour answer the same way Tank is; Rocket Artillery is the + // anti-fortification specialist the same way Rocket Tank leans into a base of structures. + weights.megatank *= 1 + (1 - armourShare) * 0.5 + armourShare * 0.6; + weights.rocketartillery *= 1 + (structures > 2 ? 0.6 : 0); } // Enemy aircraft are answered by fighters first and by the guided-missile units second — // those are the only ground things that can shoot up at all, so the counter is far sharper diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index 8634a46..4798de6 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -2829,10 +2829,21 @@ section('12b. AI build repertoire'); const n = (id) => (built[id] ?? 0); console.log(` ${games} games · towers ${n('lasertower')} · launchers ${n('missilelauncher')}` + ` · airfields ${n('airfield')} · fighters ${n('fighter')} · bombers ${n('bomber')}`); + console.log(` advanced vehicle plants ${n('advancedvehicleplant')} · megatanks ${n('megatank')}` + + ` · rocket artillery ${n('rocketartillery')} · advanced metal gens ${n('advancedmassgen')}` + + ` · nuclear plants ${n('nuclearplant')}`); check('the AI builds Laser Towers', n('lasertower') > 0, 'never built one'); check('the AI builds Missile Launchers', n('missilelauncher') > 0, 'never built one'); check('the AI builds Airfields', n('airfield') > 0, 'never built one'); check('the AI produces aircraft', n('fighter') + n('bomber') > 0, 'never built any'); + // Same "dead data unless the AI actually builds it" bar, for the Advanced Vehicle Plant tier + // and the generator upgrades — added alongside the Rocket Artillery, since a unit or + // building the AI never reaches is exactly as untested as one that doesn't exist. + check('the AI builds Advanced Vehicle Plants', n('advancedvehicleplant') > 0, 'never built one'); + check('the AI produces Advanced Vehicle Plant units', + n('megatank') + n('rocketartillery') + n('advancedconstructor') > 0, 'never built any'); + check('the AI upgrades generators', + n('advancedmassgen') + n('nuclearplant') > 0, 'never upgraded one'); // Defence is a reaction, not a habit. An AI that answers every game with a wall of towers // has stopped building an army, and the skill ladder above is measuring the wrong thing. check('defence stays a minority of construction',