TA AI Updates

This commit is contained in:
Brian Fertig 2026-08-17 21:15:32 -06:00
parent 73de46e0f1
commit 625ced5649
2 changed files with 197 additions and 15 deletions

View File

@ -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

View File

@ -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',