fertig-classic-games/src/games/totalannihilation/TALogic.js

1741 lines
68 KiB
JavaScript

// Total Annihilation — the simulation.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// Contract with the view: the scene NEVER mutates state. It calls issueOrder() for player
// commands and step()/tick() to advance time; tick() returns an event list the view replays
// as animation. The AI calls the exact same issueOrder(), which is what lets the verify
// script bot the human's side and assert that a campaign mission is actually winnable.
//
// Determinism: all randomness comes from rngNext(state) (mulberry32 seeded into state), so a
// seed plus an order log replays exactly. Never call Math.random() in this file, TANav.js,
// TAAI.js or TAMapGen.js.
import {
createNav, stampFootprint, clearanceFor, findPath, smoothPath, formationSlots,
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
SpatialHash, segmentClear,
} from './TANav.js';
export const SAVE_VERSION = 1;
// ---------------------------------------------------------------------------
// RNG — mulberry32, state carried in the match so saves resume deterministically
// ---------------------------------------------------------------------------
export function rngNext(state) {
let t = (state.rngState = (state.rngState + 0x6d2b79f5) >>> 0);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function rngRange(state, lo, hi) { return lo + rngNext(state) * (hi - lo); }
export function rngInt(state, n) { return Math.floor(rngNext(state) * n); }
// ---------------------------------------------------------------------------
// Match creation
// ---------------------------------------------------------------------------
/**
* @param {object} rules compiled rules
* @param {object} opts { seed, map:{w,h,terrain,starts,theme}, victory, armies:[{armyId,commanderId,isHuman,aiSkill}] }
*/
export function createMatch(rules, opts) {
const { map } = opts;
const c = rules.constants;
const state = {
v: SAVE_VERSION,
rulesVersion: rules.version,
seed: opts.seed >>> 0,
rngState: (opts.seed >>> 0) || 1,
tick: 0,
accumulatorMs: 0,
alpha: 0,
w: map.w, h: map.h,
tileSize: c.tileSize,
worldW: map.w * c.tileSize,
worldH: map.h * c.tileSize,
theme: map.theme ?? rules.skirmish.defaults.theme,
terrain: map.terrain,
starts: (map.starts ?? []).map((s) => ({ army: s.army, x: s.x, y: s.y })),
armies: [],
entities: [],
nextId: 1,
projectiles: [],
nextProjectileId: 1,
pathQueue: [],
events: [],
over: null,
elapsedSec: 0,
// 'commander' — an army dies with its Commander. 'annihilation' — it fights on until it
// can no longer produce. Skirmish setup picks; everything else takes the rules default.
victory: opts.victory ?? rules.constants.victoryDefault ?? 'commander',
};
state.nav = createNav(rules, map);
state.hash = new SpatialHash(128, state.worldW, state.worldH);
const visW = Math.ceil(map.w / 2), visH = Math.ceil(map.h / 2);
state.visW = visW; state.visH = visH;
for (let i = 0; i < opts.armies.length; i++) {
const spec = opts.armies[i];
state.armies.push({
idx: i,
armyId: spec.armyId,
commanderId: spec.commanderId ?? null,
isHuman: !!spec.isHuman,
aiSkill: spec.aiSkill ?? 3,
aiProfile: spec.aiProfile ?? null,
energy: spec.startEnergy ?? c.startEnergy,
mass: spec.startMass ?? c.startMass,
energyCap: c.baseEnergyCap,
massCap: c.baseMassCap,
eIncome: 0, mIncome: 0, eUpkeep: 0, eDrain: 0, mDrain: 0,
stallE: 1, stallM: 1,
alive: true, hadCommander: false,
explored: new Uint8Array(visW * visH),
visible: new Uint8Array(visW * visH),
builtEver: 0, lostEver: 0, killsEver: 0,
});
}
// Starting units.
for (const s of map.starts ?? []) {
if (s.army >= state.armies.length) continue;
for (const unitId of rules.skirmish.startUnits) {
spawnUnit(state, rules, s.army, unitId,
tileCenterX(state.nav, s.x), tileCenterY(state.nav, s.y), Math.PI / 2);
}
}
// Anything the map pre-places (campaign missions use this).
for (const u of map.units ?? []) {
spawnUnit(state, rules, u.army, u.type, u.x * c.tileSize, u.y * c.tileSize, u.heading ?? 0);
}
for (const b of map.buildings ?? []) {
const e = placeBuilding(state, rules, b.army, b.type, b.tx, b.ty);
if (e) { e.progress = 1; e.site = false; e.hp = rules.defById[b.type].hp; onBuildingComplete(state, rules, e); }
}
// Only an army that FIELDED a Commander can lose by losing it. A scenario that hands an
// army nothing but a garrison would otherwise be eliminated on its first tick.
for (const a of state.armies) a.hadCommander = armyHasCommander(state, rules, a.idx);
recomputeEconomyCaps(state, rules);
computeVision(state, rules);
return state;
}
function armyHasCommander(state, rules, armyIdx) {
return state.entities.some((e) => !e.dead && !e.site && e.army === armyIdx
&& rules.defById[e.defId]?.isCommander);
}
// ---------------------------------------------------------------------------
// Entities
// ---------------------------------------------------------------------------
function baseEntity(state, army, def) {
return {
id: state.nextId++,
army,
defId: def.id,
isBuilding: !!def.isBuilding,
x: 0, y: 0, px: 0, py: 0,
heading: 0, pheading: 0,
turretRot: 0, pturretRot: 0,
vx: 0, vy: 0,
hp: def.hp, maxHp: def.hp,
dead: false,
site: false, progress: 1,
orders: [], path: null, pathIdx: 0,
destX: 0, destY: 0, slotX: 0, slotY: 0,
wantPath: false, noPath: false,
stuckTicks: 0, blockedTicks: 0, stuckCheckTick: null, stuckCheckX: 0, stuckCheckY: 0,
// Far enough in the past that a fresh unit is eligible to regenerate immediately.
// A finite sentinel rather than -Infinity, which JSON.stringify turns into null.
lastDamagedTick: -1e9,
targetId: 0, reload: (def.weaponDefs ?? []).map(() => 0),
burstLeft: (def.weaponDefs ?? []).map(() => 0),
buildTargetId: 0,
queue: [], jobProgress: 0, rallyX: 0, rallyY: 0, hasRally: false,
_power: 0, _repairPower: 0,
produceE: 0, produceM: 0, upkeepE: 0, storeE: 0, storeM: 0,
};
}
export function spawnUnit(state, rules, army, defId, x, y, heading = 0) {
const def = rules.unitById[defId];
if (!def) return null;
const e = baseEntity(state, army, def);
e.x = e.px = x;
e.y = e.py = y;
e.heading = e.pheading = heading;
e.turretRot = e.pturretRot = heading;
e.radius = def.radius;
e.destX = x; e.destY = y;
e.rallyX = x; e.rallyY = y;
if (def.produce) { e.produceE = def.produce.energy ?? 0; e.produceM = def.produce.mass ?? 0; }
if (def.storage) { e.storeE = def.storage.energy ?? 0; e.storeM = def.storage.mass ?? 0; }
state.entities.push(e);
state.armies[army].builtEver++;
state.events.push({ t: 'spawn', id: e.id, army, defId, x, y });
return e;
}
/** Create a build SITE. It blocks the nav grid immediately so pathing routes around it. */
export function placeBuilding(state, rules, army, defId, tx, ty) {
const def = rules.buildingById[defId];
if (!def) return null;
const ts = state.tileSize;
const e = baseEntity(state, army, def);
e.tx = tx; e.ty = ty;
e.fw = def.footprint.w; e.fh = def.footprint.h;
e.x = e.px = (tx + e.fw / 2) * ts;
e.y = e.py = (ty + e.fh / 2) * ts;
e.radius = def.radius;
e.site = true;
e.progress = 0;
e.hp = Math.max(1, def.hp * 0.05);
state.entities.push(e);
stampFootprint(state.nav, rules, tx, ty, e.fw, e.fh, true);
state.events.push({ t: 'siteCreated', id: e.id, army, defId, x: e.x, y: e.y });
return e;
}
function onBuildingComplete(state, rules, e) {
const def = rules.buildingById[e.defId];
e.site = false;
e.progress = 1;
e.hp = def.hp;
if (def.produce) {
e.produceE = def.produce.energy ?? 0;
let m = def.produce.mass ?? 0;
// Terrain bonus (a Mass Generator on a metal patch): resolved once, at completion.
if (m > 0 && def.terrainMultiplier) {
let best = 1;
for (let y = e.ty; y < e.ty + e.fh; y++) {
for (let x = e.tx; x < e.tx + e.fw; x++) {
if (x < 0 || y < 0 || x >= state.w || y >= state.h) continue;
const t = rules.terrain[state.terrain[y * state.w + x]];
const mul = t?.[def.terrainMultiplier];
if (mul > best) best = mul;
}
}
m *= best;
e.terrainBonus = best;
}
e.produceM = m;
}
if (def.upkeep) e.upkeepE = def.upkeep.energy ?? 0;
if (def.storage) { e.storeE = def.storage.energy ?? 0; e.storeM = def.storage.mass ?? 0; }
recomputeEconomyCaps(state, rules);
state.events.push({ t: 'buildingComplete', id: e.id, army: e.army, defId: e.defId, x: e.x, y: e.y });
}
export function entityById(state, id) {
if (!id) return null;
for (let i = 0; i < state.entities.length; i++) {
const e = state.entities[i];
if (e.id === id) return e.dead ? null : e;
}
return null;
}
function defOf(rules, e) { return rules.defById[e.defId]; }
// ---------------------------------------------------------------------------
// Order API — the single mutation entry point shared by the human and the AI
// ---------------------------------------------------------------------------
const ORDER_TYPES = new Set([
'move', 'attackMove', 'attack', 'stop', 'hold', 'patrol', 'guard', 'build', 'assist', 'repair',
]);
/**
* @param {object} cmd { army, unitIds:[], order:{type,...}, queue:boolean }
* @returns {{ok:boolean, error?:string}}
*/
export function issueOrder(state, rules, cmd) {
if (state.over) return { ok: false, error: 'match is over' };
const { army, order } = cmd;
if (!order) return { ok: false, error: 'no order' };
// Factory / rally commands act on a building, not on a unit's order queue.
if (order.type === 'factoryEnqueue' || order.type === 'factoryCancel' || order.type === 'setRally') {
return factoryCommand(state, rules, army, order);
}
if (!ORDER_TYPES.has(order.type)) return { ok: false, error: `unknown order "${order.type}"` };
const units = [];
for (const id of cmd.unitIds ?? []) {
const e = entityById(state, id);
if (!e || e.army !== army || e.isBuilding || e.site) continue;
units.push(e);
}
if (!units.length) return { ok: false, error: 'no controllable units' };
if (order.type === 'build') return buildCommand(state, rules, army, units, order, cmd.queue);
if (order.type === 'attack') {
const target = entityById(state, order.targetId);
if (!target) return { ok: false, error: 'target does not exist' };
if (target.army === army && !rules.constants.friendlyFire) {
return { ok: false, error: 'cannot attack an ally' };
}
}
if (order.type === 'assist') {
const target = entityById(state, order.targetId);
if (!target || target.army !== army) return { ok: false, error: 'can only assist your own' };
if (!canBuild(rules, units[0]) ) return { ok: false, error: 'unit cannot assist' };
}
if (order.type === 'repair') {
const target = entityById(state, order.targetId);
if (!target || target.army !== army) return { ok: false, error: 'can only repair your own' };
if (target.site) return { ok: false, error: 'use assist on a building under construction' };
if (!units.some((u) => canBuild(rules, u))) return { ok: false, error: 'no builder selected' };
if (target.hp >= target.maxHp) return { ok: false, error: 'already at full health' };
}
if (order.type === 'guard') {
const target = entityById(state, order.targetId);
if (!target || target.army !== army) return { ok: false, error: 'can only guard your own' };
}
const moveLike = order.type === 'move' || order.type === 'attackMove' || order.type === 'patrol';
if (moveLike) {
if (!Number.isFinite(order.x) || !Number.isFinite(order.y)) return { ok: false, error: 'bad destination' };
// One shared destination, fanned into formation slots — a 40-unit move order costs one A*.
const slots = formationSlots(order.x, order.y, units);
units.forEach((u, i) => {
const o = { ...order, sx: slots[i * 2], sy: slots[i * 2 + 1] };
if (order.type === 'patrol') { o.fromX = u.x; o.fromY = u.y; o.leg = 0; }
pushOrder(u, o, cmd.queue);
});
return { ok: true };
}
for (const u of units) pushOrder(u, { ...order }, cmd.queue);
return { ok: true };
}
function pushOrder(e, order, queue) {
if (!queue) {
e.orders.length = 0;
e.path = null;
e.wantPath = false;
e.noPath = false;
// Drop the nanolathe link here rather than waiting for stepOrders to notice. Economy runs
// before orders within a tick, so leaving it set would bill the player for one more tick
// of a build or repair they just cancelled.
e.buildTargetId = 0;
}
e.orders.push(order);
}
function canBuild(rules, e) {
const def = defOf(rules, e);
return !!(def.builds ?? []).length && def.buildPower > 0;
}
function buildCommand(state, rules, army, units, order, queue) {
const builder = units.find((u) => canBuild(rules, u));
if (!builder) return { ok: false, error: 'no builder selected' };
const def = rules.buildingById[order.defId];
if (!def) return { ok: false, error: `unknown building "${order.defId}"` };
const bdef = defOf(rules, builder);
if (!(bdef.builds ?? []).includes(order.defId)) {
return { ok: false, error: `${bdef.name} cannot build ${def.name}` };
}
const legal = canPlaceAt(state, rules, order.tx, order.ty, def);
if (!legal.ok) return legal;
const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty);
if (!site) return { ok: false, error: 'placement failed' };
pushOrder(builder, { type: 'build', targetId: site.id }, queue);
return { ok: true, siteId: site.id };
}
/**
* Remove one order from a unit's queue by index — the missing counterpart to `stop`, which
* only ever clears the whole queue. Cancelling the active order (index 0) gets the same state
* reset stepOrders' own completion paths already do, so nothing is left chasing an order that
* is no longer there.
*
* A queued BUILD order carries a live site entity from the moment it was placed (see
* placeBuilding) — stamped into the nav grid regardless of how deep in the queue it sits.
* Cancelling it before any progress has been made tears that site back out (footprint
* unstamped, entity removed, any other builder pointed at it freed up); once work has begun,
* only the queue slot goes away — the site, and anyone else building it, are untouched.
*/
export function cancelOrder(state, rules, army, unitId, index) {
const e = entityById(state, unitId);
if (!e || e.army !== army || !Array.isArray(e.orders) || index < 0 || index >= e.orders.length) {
return { ok: false, error: 'no such queued order' };
}
const order = e.orders[index];
if (order.type === 'build') {
const site = entityById(state, order.targetId);
if (site && site.isBuilding && (site.progress ?? 0) <= 0) {
stampFootprint(state.nav, rules, site.tx, site.ty, site.fw, site.fh, false);
site.dead = true;
for (const o of state.entities) {
if (o.buildTargetId === site.id) o.buildTargetId = 0;
if (o.targetId === site.id) o.targetId = 0;
}
}
}
e.orders.splice(index, 1);
if (index === 0) {
e.path = null; e.movingTo = null; e.targetId = 0; e.buildTargetId = 0;
e.stuckTicks = 0; e.noPath = false;
}
return { ok: true };
}
/** Can this footprint go here? Checks bounds, terrain buildability and occupancy. */
export function canPlaceAt(state, rules, tx, ty, def) {
const fw = def.footprint.w, fh = def.footprint.h;
if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) {
return { ok: false, error: 'off the map' };
}
for (let y = ty; y < ty + fh; y++) {
for (let x = tx; x < tx + fw; x++) {
const i = y * state.w + x;
if (state.nav.blocked[i]) return { ok: false, error: 'occupied' };
const t = rules.terrain[state.terrain[i]];
if (!t.buildable) return { ok: false, error: `cannot build on ${t.id}` };
}
}
return { ok: true };
}
function factoryCommand(state, rules, army, order) {
const f = entityById(state, order.factoryId);
if (!f || f.army !== army || !f.isBuilding || f.site) return { ok: false, error: 'no such factory' };
const def = defOf(rules, f);
if (order.type === 'setRally') {
f.rallyX = order.x; f.rallyY = order.y; f.hasRally = true;
return { ok: true };
}
if (order.type === 'factoryCancel') {
const idx = order.index ?? 0;
if (idx < 0 || idx >= f.queue.length) return { ok: false, error: 'bad queue index' };
const item = f.queue[idx];
const take = Math.min(item.count, order.count ?? 1);
item.count -= take;
if (idx === 0 && f.jobProgress > 0 && item.count === 0) {
// Refund what the cancelled head job actually consumed.
const udef = rules.unitById[item.defId];
const a = state.armies[army];
a.energy = Math.min(a.energyCap, a.energy + udef.cost.energy * f.jobProgress);
a.mass = Math.min(a.massCap, a.mass + udef.cost.mass * f.jobProgress);
f.jobProgress = 0;
}
if (item.count <= 0) f.queue.splice(idx, 1);
state.events.push({ t: 'queueChanged', id: f.id });
return { ok: true };
}
// factoryEnqueue
if (!(def.builds ?? []).includes(order.defId)) {
return { ok: false, error: `${def.name} cannot build ${order.defId}` };
}
const count = Math.max(1, order.count ?? 1);
const head = f.queue[f.queue.length - 1];
if (head && head.defId === order.defId) head.count += count;
else f.queue.push({ defId: order.defId, count });
state.events.push({ t: 'queueChanged', id: f.id });
return { ok: true };
}
// ---------------------------------------------------------------------------
// Economy
// ---------------------------------------------------------------------------
function recomputeEconomyCaps(state, rules) {
const c = rules.constants;
for (const a of state.armies) { a.energyCap = c.baseEnergyCap; a.massCap = c.baseMassCap; }
for (const e of state.entities) {
if (e.dead || e.site) continue;
const a = state.armies[e.army];
if (!a) continue;
a.energyCap += e.storeE;
a.massCap += e.storeM;
}
}
/**
* The flow economy. Income and drain are RATES; construction consumes continuously and
* slows proportionally when income can't keep up — that stall factor is the whole reason
* Energy and Mass Generators are separate buildings.
*/
function stepEconomy(state, rules) {
const dt = rules.dt;
const nominal = rules.constants.buildPowerNominal;
for (const a of state.armies) {
a.eIncome = 0; a.mIncome = 0; a.eUpkeep = 0; a.eDrain = 0; a.mDrain = 0;
a._mFree = 0; a._mGated = 0; a.upkeepFactor = 1;
}
for (const e of state.entities) { e._power = 0; e._repairPower = 0; }
// 1. Income and upkeep from completed entities. Mass production is split into what runs
// for free and what is gated behind an energy upkeep (the Mass Generator), because
// those two behave completely differently in a brownout — see step 4.
for (const e of state.entities) {
if (e.dead || e.site) continue;
const a = state.armies[e.army];
if (!a) continue;
a.eIncome += e.produceE;
a.eUpkeep += e.upkeepE;
if (e.upkeepE > 0) a._mGated += e.produceM;
else a._mFree += e.produceM;
}
// 2. Gather build power onto each job.
for (const e of state.entities) {
if (e.dead || e.site) continue;
const def = defOf(rules, e);
if (!def.buildPower) continue;
if (e.isBuilding) {
if (e.queue.length) e._power += def.buildPower; // factory works its own head item
} else if (e.buildTargetId) {
const target = entityById(state, e.buildTargetId);
if (target && target.army === e.army) {
const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > def.buildRange + target.radius) continue;
// Repair power is kept in its OWN accumulator. A damaged factory has build power of
// its own for the unit it is producing; folding an incoming repair into the same
// number would silently speed up that unit's production.
if (target.site) target._power += def.buildPower;
else if (target.hp < target.maxHp) target._repairPower += def.buildPower;
}
}
}
// 3. Demand.
const jobs = [];
for (const e of state.entities) {
const a = state.armies[e.army];
if (e.dead || !a) continue;
if (e._power > 0) {
let jobDef = null;
if (e.site) jobDef = defOf(rules, e);
else if (e.isBuilding && e.queue.length) jobDef = rules.unitById[e.queue[0].defId];
if (jobDef && jobDef.buildTime > 0) {
const rate = (e._power / nominal) / jobDef.buildTime; // progress fraction per second
a.eDrain += (jobDef.cost.energy ?? 0) * rate;
a.mDrain += (jobDef.cost.mass ?? 0) * rate;
jobs.push({ e, rate, army: e.army, kind: e.site ? 'site' : 'factory' });
}
}
// Repair is priced as a fraction of a fresh build: restoring X% of a unit's HP costs X%
// of its build cost and takes X% of its build time. It therefore runs through exactly the
// same rate, drain and stall machinery as construction — including being throttled in a
// brownout rather than proceeding for free.
if (e._repairPower > 0 && !e.site && e.hp < e.maxHp) {
const def = defOf(rules, e);
if (def.buildTime > 0) {
const full = (e._repairPower / nominal) / def.buildTime;
// Never bill for more than the damage actually outstanding.
const remaining = (e.maxHp - e.hp) / e.maxHp;
const rate = Math.min(full, remaining / dt);
a.eDrain += (def.cost.energy ?? 0) * rate;
a.mDrain += (def.cost.mass ?? 0) * rate;
jobs.push({ e, rate, army: e.army, kind: 'repair' });
}
}
}
// 4. Stall factors, then apply.
//
// Claim order on energy is CONSTRUCTION FIRST, upkeep second. That ordering matters:
// if upkeep were paid first, an army that over-built Mass Generators (upkeep > income)
// would pin its energy at zero, drop build efficiency to zero, and be permanently
// unable to finish the very Energy Generators that would rescue it. Throttling the
// metal makers instead is both self-correcting and what a TA player does by hand.
for (const a of state.armies) {
const eAvail = Math.max(0, a.energy + a.eIncome * dt);
const eNeed = a.eDrain * dt;
a.stallE = eNeed > eAvail ? (eNeed > 0 ? eAvail / eNeed : 1) : 1;
const eLeft = Math.max(0, eAvail - eNeed * a.stallE);
const upkeepNeed = a.eUpkeep * dt;
a.upkeepFactor = upkeepNeed > 0 ? Math.min(1, eLeft / upkeepNeed) : 1;
// Gated producers only deliver in proportion to the upkeep they were actually paid.
a.mIncome = a._mFree + a._mGated * a.upkeepFactor;
const mAvail = Math.max(0, a.mass + a.mIncome * dt);
const mNeed = a.mDrain * dt;
a.stallM = mNeed > mAvail ? (mNeed > 0 ? mAvail / mNeed : 1) : 1;
a.buildEff = Math.min(a.stallE, a.stallM);
if (a.buildEff < 0.999 && !a._stalling) {
a._stalling = true;
state.events.push({ t: 'stallStart', army: a.idx, energy: a.stallE < 0.999, mass: a.stallM < 0.999 });
} else if (a.buildEff >= 0.999 && a._stalling) {
a._stalling = false;
state.events.push({ t: 'stallEnd', army: a.idx });
}
}
for (const j of jobs) {
const a = state.armies[j.army];
j.adv = j.rate * a.buildEff * dt;
}
for (const a of state.armies) {
const f = a.buildEff;
const eNext = a.energy + (a.eIncome - a.eUpkeep * a.upkeepFactor - a.eDrain * f) * dt;
const mNext = a.mass + (a.mIncome - a.mDrain * f) * dt;
if (eNext > a.energyCap || mNext > a.massCap) {
state.events.push({ t: 'wasting', army: a.idx, energy: eNext > a.energyCap, mass: mNext > a.massCap });
}
a.energy = Math.max(0, Math.min(a.energyCap, eNext));
a.mass = Math.max(0, Math.min(a.massCap, mNext));
}
// 5. Advance construction and repair.
for (const j of jobs) {
const e = j.e;
const adv = j.adv ?? 0;
if (adv <= 0) continue;
if (j.kind === 'repair') {
e.hp = Math.min(e.maxHp, e.hp + e.maxHp * adv);
state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army });
if (e.hp >= e.maxHp) {
// Release the builders so they fall through to whatever they were told to do next.
for (const b of state.entities) {
if (b.buildTargetId !== e.id) continue;
b.buildTargetId = 0;
if (b.orders[0]?.type === 'repair' && b.orders[0].targetId === e.id) b.orders.shift();
}
}
continue;
}
if (e.site) {
const def = defOf(rules, e);
e.progress = Math.min(1, e.progress + adv);
e.hp = Math.max(1, def.hp * Math.max(0.05, e.progress));
state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army });
if (e.progress >= 1) {
onBuildingComplete(state, rules, e);
// Release any builder that was working on it.
for (const b of state.entities) {
if (b.buildTargetId === e.id) {
b.buildTargetId = 0;
if (b.orders[0]?.type === 'build' && b.orders[0].targetId === e.id) b.orders.shift();
}
}
}
} else {
e.jobProgress = Math.min(1, e.jobProgress + adv);
state.events.push({ t: 'nanolathe', id: e.id, x: e.x, y: e.y, army: e.army });
if (e.jobProgress >= 1) finishFactoryJob(state, rules, e);
}
}
}
function finishFactoryJob(state, rules, f) {
const item = f.queue[0];
if (!item) { f.jobProgress = 0; return; }
const def = defOf(rules, f);
const off = def.spawnOffset ?? { x: 0, y: 1 };
const ts = state.tileSize;
let sx = f.x + off.x * ts;
let sy = f.y + off.y * ts;
// Nudge out of the footprint if the bay mouth is somehow blocked.
const nav = state.nav;
const udef = rules.unitById[item.defId];
const need = clearanceFor(udef.radius, ts);
const mc = udef.moveClass;
let tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy);
const idx = nearestUsableTile(nav, mc, need, tx, ty, 6);
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); }
const u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x));
f.jobProgress = 0;
item.count--;
if (item.count <= 0) f.queue.shift();
state.events.push({ t: 'queueChanged', id: f.id });
if (u && f.hasRally) {
issueOrder(state, rules, {
army: f.army, unitIds: [u.id], order: { type: 'move', x: f.rallyX, y: f.rallyY }, queue: false,
});
}
}
// ---------------------------------------------------------------------------
// Orders and movement
// ---------------------------------------------------------------------------
function requestPath(state, e, x, y) {
e.destX = x; e.destY = y;
e.wantPath = true;
e.noPath = false;
if (!state.pathQueue.includes(e.id)) state.pathQueue.push(e.id);
}
function servicePathQueue(state, rules) {
let budget = rules.constants.pathBudgetPerTick;
while (budget-- > 0 && state.pathQueue.length) {
const id = state.pathQueue.shift();
const e = entityById(state, id);
if (!e || !e.wantPath) continue;
const def = defOf(rules, e);
const nav = state.nav;
const need = clearanceFor(e.radius, state.tileSize);
const mc = def.moveClass;
const sIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.x), worldToTileY(nav, e.y), 6);
const gIdx = nearestUsableTile(nav, mc, need, worldToTileX(nav, e.destX), worldToTileY(nav, e.destY), 24);
e.wantPath = false;
if (sIdx < 0 || gIdx < 0) { e.noPath = true; e.path = null; continue; }
const tiles = findPath(nav, mc, need, sIdx, gIdx);
if (!tiles) { e.noPath = true; e.path = null; state.events.push({ t: 'noPath', id: e.id }); continue; }
e.path = smoothPath(nav, mc, need, tiles, e.destX, e.destY);
e.pathIdx = 0;
}
}
function orderDestination(e, order) {
if (order.type === 'patrol') {
return order.leg === 0 ? { x: order.sx ?? order.x, y: order.sy ?? order.y }
: { x: order.fromX, y: order.fromY };
}
return { x: order.sx ?? order.x, y: order.sy ?? order.y };
}
function stepOrders(state, rules) {
const ts = state.tileSize;
for (const e of state.entities) {
if (e.dead || e.site) continue;
if (e.isBuilding) continue;
const def = defOf(rules, e);
const order = e.orders[0];
if (!order) {
e.movingTo = null;
e.buildTargetId = 0;
continue;
}
// A builder only holds a nanolathe target while its CURRENT order is a construction one.
// Without this, giving a repairing Commander a move order left buildTargetId pointing at
// the patient, and the economy kept pouring resources into the repair — for as long as
// the builder happened to stay in range — even though the player had cancelled it.
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair') {
e.buildTargetId = 0;
}
switch (order.type) {
case 'stop':
e.orders.length = 0;
e.path = null;
e.targetId = 0;
e.buildTargetId = 0;
break;
case 'hold':
e.path = null;
e.movingTo = null;
break;
case 'move':
case 'attackMove':
case 'patrol': {
// An attack-move that has closed on a hostile BUILDING stops as soon as it is
// comfortably inside weapon range of that building's surface, and holds there while
// it shoots. Otherwise the unit keeps driving at its move destination — which for a
// base assault is a point inside a building — and grinds against the wall of
// something it could have killed from a screen away.
//
// Deliberately buildings only. Applying it to mobile targets as well made every
// squad halt at maximum range the instant it saw anything, which took the sting out
// of attacking altogether and flattened the AI skill ladder from 90% to 53%.
if (order.type === 'attackMove' && def.maxRange > 0) {
const foe = entityById(state, e.targetId);
if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army]
&& surfaceDist(rules, e, foe) <= def.maxRange * (rules.constants.engageHoldFraction ?? 0.85)) {
e.path = null;
e.movingTo = null;
e.stuckTicks = 0;
e.noPath = false;
break;
}
}
const dest = orderDestination(e, order);
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, dest.x, dest.y);
e.movingTo = dest;
const d = Math.hypot(dest.x - e.x, dest.y - e.y);
const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2);
if (d <= slack || (e.noPath && e.stuckTicks > rules.constants.stuckGiveUpSec * rules.constants.tickHz)) {
if (order.type === 'patrol') {
order.leg = order.leg === 0 ? 1 : 0;
e.path = null; e.noPath = false; e.stuckTicks = 0;
requestPath(state, e, ...(order.leg === 0
? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY]));
} else {
e.orders.shift();
e.path = null; e.movingTo = null; e.stuckTicks = 0; e.noPath = false;
}
}
break;
}
case 'attack': {
const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
e.targetId = target.id;
const d = surfaceDist(rules, e, target);
const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85);
if (d > wantRange) {
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
// Re-path when the target has drifted well away from where we aimed.
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 3) {
e.path = null; requestPath(state, e, target.x, target.y);
}
} else {
e.path = null;
e.movingTo = null;
}
break;
}
case 'guard': {
const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); break; }
const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > ts * 3) {
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 2) {
e.path = null; requestPath(state, e, target.x, target.y);
}
} else {
e.path = null; e.movingTo = null;
}
break;
}
case 'build':
case 'assist':
case 'repair': {
const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); e.buildTargetId = 0; e.path = null; break; }
if (order.type === 'assist' && !target.site && !target.queue?.length) {
e.orders.shift(); e.buildTargetId = 0; break;
}
// Nothing left to mend — either it was topped up by someone else or it was never
// damaged by the time we arrived.
if (order.type === 'repair' && (target.hp >= target.maxHp || target.site)) {
e.orders.shift(); e.buildTargetId = 0; break;
}
const reach = (def.buildRange ?? 0) + target.radius;
const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > reach * 0.9) {
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
e.buildTargetId = 0;
} else {
e.path = null;
e.movingTo = null;
e.buildTargetId = target.id;
}
break;
}
default:
e.orders.shift();
}
}
}
function stepMovement(state, rules) {
const dt = rules.dt;
const nav = state.nav;
for (const e of state.entities) {
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
e._movingThisTick = false;
if (e.dead || e.isBuilding || e.site) { e.vx = 0; e.vy = 0; continue; }
const def = defOf(rules, e);
let tx = null, ty = null;
if (e.path && e.path.length >= 2) {
// Consume waypoints we've reached.
while (e.pathIdx * 2 + 1 < e.path.length) {
const wx = e.path[e.pathIdx * 2], wy = e.path[e.pathIdx * 2 + 1];
const last = (e.pathIdx + 1) * 2 >= e.path.length;
const tol = last ? Math.max(6, e.radius * 0.6) : Math.max(10, e.radius);
if (Math.hypot(wx - e.x, wy - e.y) <= tol) e.pathIdx++;
else break;
}
if (e.pathIdx * 2 + 1 < e.path.length) {
tx = e.path[e.pathIdx * 2]; ty = e.path[e.pathIdx * 2 + 1];
} else {
e.path = null;
}
} else if (e.movingTo && e.noPath) {
tx = e.movingTo.x; ty = e.movingTo.y;
}
if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; }
const dx = tx - e.x, dy = ty - e.y;
const dist = Math.hypot(dx, dy);
if (dist < 0.001) { e.vx = 0; e.vy = 0; continue; }
const want = Math.atan2(dy, dx);
e.heading = turnToward(e.heading, want, def.turnRate * dt);
// Terrain slows movement: speed scales by the inverse of the tile's move cost.
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x);
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1;
const terrainMul = tcost ? 1 / tcost : 1;
// Slow down while still swinging round, and ease into the final waypoint.
const misalign = Math.abs(angleDelta(e.heading, want));
const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1);
const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1;
const speed = def.speed * terrainMul * turnMul * arriveMul;
const step = Math.min(speed * dt, dist);
e.vx = Math.cos(e.heading) * step / dt;
e.vy = Math.sin(e.heading) * step / dt;
e.x += Math.cos(e.heading) * step;
e.y += Math.sin(e.heading) * step;
e._movingThisTick = true;
}
}
/**
* Stuck detection, run after stepSeparation so it sees where units actually ended up rather
* than where stepMovement merely aimed them. A unit whose intended step this tick was fine on
* paper can still be shoved straight back by the building-clearance clamp in stepSeparation.
*
* This measures NET displacement over a whole `stuckRepathSec` window rather than per-tick
* displacement. A unit wedged against the corner of a wall doesn't sit still — it gets nudged
* one way by the clearance clamp, drives back toward its blocked waypoint, gets nudged again —
* and every one of those individual ticks moves it plenty. Only the position at the start and
* end of the window tells you it never actually got anywhere.
*
* A path that stops making real progress gets thrown away and re-requested against the
* CURRENT nav grid, which is what makes a unit route around an obstacle it didn't know about
* when the path was first computed (e.g. a building that went up mid-transit) instead of
* grinding against the same blocked waypoint forever.
*/
function finalizeStuck(state, rules) {
const windowTicks = Math.max(1, Math.round((rules.constants.stuckRepathSec ?? 1.0) * rules.constants.tickHz));
for (const e of state.entities) {
if (!e._movingThisTick) { e.stuckCheckTick = null; continue; }
if (e.stuckCheckTick == null) {
e.stuckCheckTick = state.tick; e.stuckCheckX = e.x; e.stuckCheckY = e.y;
continue;
}
if (state.tick - e.stuckCheckTick < windowTicks) continue;
const def = defOf(rules, e);
const moved = Math.hypot(e.x - e.stuckCheckX, e.y - e.stuckCheckY);
const expected = def.speed * (windowTicks / rules.constants.tickHz);
e.stuckCheckTick = state.tick; e.stuckCheckX = e.x; e.stuckCheckY = e.y;
if (moved >= expected * 0.15) { e.stuckTicks = 0; continue; }
e.stuckTicks += windowTicks;
if (e.path) { e.path = null; requestPath(state, e, e.destX, e.destY); }
}
}
function turnToward(cur, want, maxStep) {
const d = angleDelta(cur, want);
if (Math.abs(d) <= maxStep) return want;
return normalizeAngle(cur + Math.sign(d) * maxStep);
}
export function angleDelta(from, to) {
let d = (to - from) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return d;
}
function normalizeAngle(a) {
a %= Math.PI * 2;
if (a > Math.PI) a -= Math.PI * 2;
if (a < -Math.PI) a += Math.PI * 2;
return a;
}
/**
* Positional separation. This is what makes the size classes real: three r=16 units settle
* inside one 64px tile, an r=26 tank takes the tile alone, and heavier units shove lighter
* ones aside rather than the other way round.
*/
function stepSeparation(state, rules) {
const stiff = rules.constants.separationStiffness;
const shoveTicks = rules.constants.shoveAfterSec * rules.constants.tickHz;
const hash = state.hash;
hash.clear();
const movers = [];
for (const e of state.entities) {
if (e.dead || e.isBuilding || e.site) continue;
hash.insert(e, e.x, e.y);
movers.push(e);
}
const near = [];
for (const a of movers) {
near.length = 0;
hash.query(a.x, a.y, a.radius * 2 + 32, near);
for (const b of near) {
if (b.id <= a.id) continue; // handle each pair once, deterministically
const dx = b.x - a.x, dy = b.y - a.y;
const minD = a.radius + b.radius;
let d2 = dx * dx + dy * dy;
if (d2 >= minD * minD) continue;
let d = Math.sqrt(d2);
let nx, ny;
if (d < 0.0001) {
// Exactly coincident: push apart along a deterministic axis derived from ids.
const ang = ((a.id * 2654435761 + b.id) % 628) / 100;
nx = Math.cos(ang); ny = Math.sin(ang); d = 0.0001;
} else {
nx = dx / d; ny = dy / d;
}
const overlap = (minD - d) * stiff;
const da = defOf(rules, a), db = defOf(rules, b);
const ma = da.massClass ?? 1, mb = db.massClass ?? 1;
const total = ma + mb;
const aShare = mb / total, bShare = ma / total;
a.x -= nx * overlap * aShare; a.y -= ny * overlap * aShare;
b.x += nx * overlap * bShare; b.y += ny * overlap * bShare;
// Traffic-jam breaker: a unit trying to move that's stuck behind a parked friendly
// tells the blocker to step aside. Without this, groups deadlock in corridors.
if (a.army === b.army) {
const aMoving = !!(a.path || a.movingTo), bMoving = !!(b.path || b.movingTo);
if (aMoving && !bMoving) { b.blockedTicks++; if (b.blockedTicks > shoveTicks) shove(state, rules, b, nx, ny); }
else if (bMoving && !aMoving) { a.blockedTicks++; if (a.blockedTicks > shoveTicks) shove(state, rules, a, -nx, -ny); }
}
}
}
// Push everything out of blocked tiles and back inside the map.
const nav = state.nav;
const ts = state.tileSize;
for (const e of movers) {
const def = defOf(rules, e);
const need = clearanceFor(e.radius, ts);
const cl = nav.clearance[def.moveClass];
const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y);
if (cl[ty * nav.w + tx] < need) {
const idx = nearestUsableTile(nav, def.moveClass, need, tx, ty, 8);
if (idx >= 0) {
const cx = tileCenterX(nav, idx % nav.w), cy = tileCenterY(nav, (idx / nav.w) | 0);
const d = Math.hypot(cx - e.x, cy - e.y) || 1;
e.x += ((cx - e.x) / d) * Math.min(ts * 0.5, d);
e.y += ((cy - e.y) / d) * Math.min(ts * 0.5, d);
}
}
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));
}
}
function shove(state, rules, e, nx, ny) {
e.blockedTicks = 0;
const ts = state.tileSize;
// Step perpendicular to the pusher, whichever side is open.
const target = { x: e.x - ny * ts * 1.5, y: e.y + nx * ts * 1.5 };
issueOrder(state, rules, {
army: e.army, unitIds: [e.id], order: { type: 'move', x: target.x, y: target.y }, queue: false,
});
}
// ---------------------------------------------------------------------------
// Combat
// ---------------------------------------------------------------------------
function stepCombat(state, rules) {
const c = rules.constants;
const dt = rules.dt;
const hash = state.hash;
const near = [];
for (const e of state.entities) {
if (e.dead || e.site) continue;
const def = defOf(rules, e);
if (!def.weaponDefs?.length) continue;
for (let wi = 0; wi < e.reload.length; wi++) if (e.reload[wi] > 0) e.reload[wi]--;
// Staggered acquisition — the single biggest CPU lever in the sim. Re-scan only every
// retargetPeriodTicks, phase-shifted per unit so the cost spreads across ticks.
const holdOrder = e.orders[0]?.type === 'attack';
let target = entityById(state, e.targetId);
if (target && !inWeaponRange(rules, e, def, target)) target = null;
const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0;
if (!holdOrder && (!target || due)) {
target = acquireTarget(state, rules, e, def, hash, near);
e.targetId = target ? target.id : 0;
} else if (holdOrder) {
target = entityById(state, e.orders[0].targetId) ?? target;
e.targetId = target ? target.id : 0;
}
if (!target) continue;
const aimPt = aimPointOn(rules, e, target);
const dx = aimPt.x - e.x, dy = aimPt.y - e.y;
const aim = Math.atan2(dy, dx);
const surface = surfaceDist(rules, e, target);
const hasTurret = def.turretFrame != null;
// A structure has no hull to swing round: its mount traverses freely and is always on
// target. Without this a defensive tower could only ever shoot due east — a building's
// heading is fixed at 0 and nothing ever turns it.
if (e.isBuilding) e.turretRot = aim;
else if (hasTurret) e.turretRot = turnToward(e.turretRot, aim, def.turnRate * 2.5 * dt);
else e.turretRot = e.heading;
const facing = (e.isBuilding || hasTurret) ? e.turretRot : e.heading;
// A unit that's driving somewhere still shoots; it just has to be roughly on target.
const aligned = e.isBuilding || Math.abs(angleDelta(facing, aim)) < (hasTurret ? 0.12 : 0.35);
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi];
if (e.reload[wi] > 0) continue;
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked.
if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
const dist = surface;
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
if (!aligned) continue;
if (w.kind === 'beam' && w.energyPerShot) {
const a = state.armies[e.army];
if (a.energy < w.energyPerShot) continue;
a.energy -= w.energyPerShot;
}
fireWeapon(state, rules, e, w, target, facing, aimPt);
e.burstLeft[wi] = (w.burst ?? 1) - 1;
e.reload[wi] = e.burstLeft[wi] > 0 ? w.burstDelayTicks : w.reloadTicks;
if (e.burstLeft[wi] <= 0) e.burstLeft[wi] = 0;
else e._burstWeapon = wi;
}
// Continue an in-flight burst.
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi];
if (w.manual) continue;
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
fireWeapon(state, rules, e, w, target, facing, aimPt);
e.burstLeft[wi]--;
e.reload[wi] = e.burstLeft[wi] > 0 ? w.burstDelayTicks : w.reloadTicks;
}
}
}
}
/**
* Gap from `from`'s centre to the target's SURFACE — the rectangle for a building, a circle
* for anything else. Zero when `from` is inside the footprint.
*
* A building is a box, not a disc: treating a 3x3 plant as a circle of radius 96 understates
* it by 40px on the diagonal, so units had to close well past their real firing distance
* before the range test agreed they had arrived.
*/
export function surfaceDist(rules, from, target) {
if (!target.isBuilding) {
return Math.hypot(target.x - from.x, target.y - from.y) - target.radius;
}
const def = defOf(rules, target);
const dx = Math.max(Math.abs(from.x - target.x) - (def.halfW ?? target.radius), 0);
const dy = Math.max(Math.abs(from.y - target.y) - (def.halfH ?? target.radius), 0);
return Math.hypot(dx, dy);
}
/** The point on the target actually shot at: the nearest bit of a building's face. */
export function aimPointOn(rules, from, target) {
if (!target.isBuilding) return { x: target.x, y: target.y };
const def = defOf(rules, target);
const hw = def.halfW ?? target.radius, hh = def.halfH ?? target.radius;
return {
x: Math.min(Math.max(from.x, target.x - hw), target.x + hw),
y: Math.min(Math.max(from.y, target.y - hh), target.y + hh),
};
}
function inWeaponRange(rules, e, def, target) {
if (target.dead) return false;
return surfaceDist(rules, e, target) <= def.maxRange;
}
function acquireTarget(state, rules, e, def, hash, near) {
near.length = 0;
hash.query(e.x, e.y, def.maxRange + 64, near);
// Buildings aren't in the movers hash, so scan them directly — there are few of them.
let best = null, bestScore = -Infinity;
const consider = (t) => {
if (t.dead || t.army === e.army) return;
if (!state.armies[t.army]) return;
const d = surfaceDist(rules, e, t);
if (d > def.maxRange) return;
let score = -Infinity;
for (const w of def.weaponDefs) {
if (d > w.range || d < (w.minRange ?? 0)) continue;
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
const s = (w.damage * mul) / (1 + d / w.range);
if (s > score) score = s;
}
if (score === -Infinity) return;
// Prefer things that can shoot back, and break ties by id so it's deterministic.
const tdef = rules.defById[t.defId];
if (tdef.weaponDefs?.length) score *= 1.35;
if (t.site) score *= 0.6;
if (score > bestScore || (score === bestScore && best && t.id < best.id)) {
best = t; bestScore = score;
}
};
for (const t of near) consider(t);
for (const t of state.entities) if (t.isBuilding && !t.dead) consider(t);
return best;
}
function fireWeapon(state, rules, e, w, target, facing, aimPt) {
const aim = aimPt ?? aimPointOn(rules, e, target);
const muzzle = (w.fx?.muzzle ?? e.radius);
const ox = e.x + Math.cos(facing) * muzzle;
const oy = e.y + Math.sin(facing) * muzzle;
state.events.push({
t: 'weaponFired', id: e.id, army: e.army, weapon: w.id,
x: ox, y: oy, heading: facing, style: w.fx.style, sound: w.sound,
});
if (w.kind === 'hitscan' || w.kind === 'beam') {
let hit = true;
if (w.spread > 0) {
// Spread becomes a miss chance that grows with range — cheap, and it means massed
// infantry still trade meaningfully at the edge of their envelope.
const d = Math.hypot(aim.x - e.x, aim.y - e.y);
const missChance = Math.min(0.6, w.spread * (d / w.range) * 3);
hit = rngNext(state) >= missChance;
}
const ex = hit ? aim.x : aim.x + rngRange(state, -24, 24);
const ey = hit ? aim.y : aim.y + rngRange(state, -24, 24);
state.events.push({
t: 'shot', style: w.fx.style, x1: ox, y1: oy, x2: ex, y2: ey,
color: w.fx.color, width: w.fx.width, lifeMs: w.fx.lifeMs ?? 80, army: e.army,
});
if (hit) {
applyDamage(state, rules, target, w.damage * (w.armorMul?.[rules.defById[target.defId].armorClass] ?? 1), e);
if (w.aoe) applyAoe(state, rules, aim.x, aim.y, w, e);
state.events.push({ t: 'impact', x: ex, y: ey, radius: w.aoe ?? 8, style: w.fx.style, sound: w.impactSound });
}
return;
}
// Travelling ordnance: leading matters, and a strafing target can genuinely be missed.
let aimX = aim.x, aimY = aim.y;
if (w.leadTarget) {
const d = Math.hypot(target.x - ox, target.y - oy);
const flight = d / w.speed;
aimX += (target.vx ?? 0) * flight;
aimY += (target.vy ?? 0) * flight;
}
let ang = Math.atan2(aimY - oy, aimX - ox);
if (w.spread > 0) ang += rngRange(state, -w.spread, w.spread);
if (state.projectiles.length >= rules.constants.projectileCap) return;
state.projectiles.push({
id: state.nextProjectileId++,
weapon: w.id, army: e.army, ownerId: e.id,
x: ox, y: oy, px: ox, py: oy,
vx: Math.cos(ang) * w.speed, vy: Math.sin(ang) * w.speed,
targetId: w.kind === 'guided' ? target.id : 0,
aimX, aimY,
ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz),
trail: [],
});
}
function stepProjectiles(state, rules) {
const dt = rules.dt;
const keep = [];
for (const p of state.projectiles) {
const w = rules.weaponById[p.weapon];
p.px = p.x; p.py = p.y;
if (w.kind === 'guided') {
const target = entityById(state, p.targetId);
if (target) {
const want = Math.atan2(target.y - p.y, target.x - p.x);
const cur = Math.atan2(p.vy, p.vx);
const next = turnToward(cur, want, w.turnRate * dt);
p.vx = Math.cos(next) * w.speed;
p.vy = Math.sin(next) * w.speed;
const gp = aimPointOn(rules, p, target);
p.aimX = gp.x; p.aimY = gp.y;
}
}
p.x += p.vx * dt;
p.y += p.vy * dt;
if (w.fx?.trail) {
p.trail.push(p.x, p.y);
if (p.trail.length > 16) p.trail.splice(0, 2);
}
let detonate = false;
if (--p.ttl <= 0) detonate = true;
if (p.x < 0 || p.y < 0 || p.x > state.worldW || p.y > state.worldH) detonate = true;
// Collision is SWEPT along this tick's segment, not sampled at the new position. A shell
// travels 35px in one 20 Hz tick — well past a 12px hit window and past most unit radii —
// so a point test made tank fire pass clean through whatever it was aimed at.
if (!detonate) {
const hitR = w.kind === 'guided' ? 14 : 12;
if (segDistSq(p.px, p.py, p.x, p.y, p.aimX, p.aimY) <= hitR * hitR) {
detonate = true;
// Detonate where the shell actually met the aim point, not where the step ended.
const s = segClosestT(p.px, p.py, p.x, p.y, p.aimX, p.aimY);
p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s;
}
}
// Direct contact with any hostile it passes through.
if (!detonate) {
let bestT = Infinity, hit = null;
for (const e of state.entities) {
if (e.dead || e.site || e.army === p.army) continue;
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
if (s < bestT) { bestT = s; hit = e; }
}
if (hit) {
detonate = true;
p.x = p.px + (p.x - p.px) * bestT; p.y = p.py + (p.y - p.py) * bestT;
}
}
if (detonate) {
state.events.push({ t: 'impact', x: p.x, y: p.y, radius: w.aoe ?? 12, style: w.fx.style, sound: w.impactSound });
if (w.aoe) applyAoe(state, rules, p.x, p.y, w, { army: p.army, id: p.ownerId });
else {
for (const e of state.entities) {
if (e.dead || e.army === p.army) continue;
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) {
applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
break;
}
}
}
} else {
keep.push(p);
}
}
state.projectiles = keep;
}
/** Parametric position along segment AB of the point on it closest to P, clamped to [0,1]. */
function segClosestT(ax, ay, bx, by, px, py) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-9) return 0;
const t = ((px - ax) * dx + (py - ay) * dy) / len2;
return t < 0 ? 0 : t > 1 ? 1 : t;
}
/** Squared distance from P to segment AB. */
function segDistSq(ax, ay, bx, by, px, py) {
const t = segClosestT(ax, ay, bx, by, px, py);
const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py;
return cx * cx + cy * cy;
}
function applyAoe(state, rules, x, y, w, source) {
const r = w.aoe;
const falloff = w.aoeFalloff ?? 0.4;
for (const e of state.entities) {
if (e.dead) continue;
if (e.army === source.army && !rules.constants.friendlyFire) continue;
// Falloff is measured to the SURFACE, so a shell that lands squarely on a building's
// face counts as a direct hit wherever along that face it strikes. Measuring from the
// centre circle instead scored an off-axis wall hit as 32px away and cut its damage by
// 80%, which made big buildings absurdly tough to shoot from any angle but dead-on.
const d = surfaceDist(rules, { x, y }, e);
if (d > r) continue;
const t = Math.max(0, Math.min(1, d / r));
// Quadratic falloff, not linear. Linear left near-full damage on every neighbour of the
// unit actually hit, so one shell wiped a whole packed formation and splash weapons beat
// everything else on cost regardless of what they were pointed at.
const scale = falloff + (1 - falloff) * (1 - t) * (1 - t);
const mul = w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1;
applyDamage(state, rules, e, w.damage * mul * scale, source);
}
}
export function applyDamage(state, rules, target, amount, source) {
if (target.dead || amount <= 0) return;
target.hp -= amount;
target.lastDamagedTick = state.tick;
state.events.push({ t: 'damaged', id: target.id, amount, x: target.x, y: target.y });
if (target.hp <= 0) killEntity(state, rules, target, source);
}
/**
* Self-repair. Only defs that declare `selfHeal` regenerate, and the clock restarts on every
* hit — so a Commander recovers between engagements but never out-heals incoming fire, and
* cannot be used to tank a fight it is losing.
*/
function stepRegen(state, rules) {
for (const e of state.entities) {
if (e.dead || e.site) continue;
const heal = defOf(rules, e).selfHeal;
if (!heal) continue;
if (e.hp >= e.maxHp) continue;
if (state.tick - e.lastDamagedTick < heal.pauseTicks) continue;
e.hp = Math.min(e.maxHp, e.hp + heal.hpPerTick);
}
}
function killEntity(state, rules, e, source) {
if (e.dead) return;
e.dead = true;
e.hp = 0;
const def = defOf(rules, e);
state.armies[e.army].lostEver++;
if (source && state.armies[source.army]) state.armies[source.army].killsEver++;
state.events.push({
t: 'unitDestroyed', id: e.id, army: e.army, defId: e.defId,
x: e.x, y: e.y, radius: e.radius, isBuilding: e.isBuilding,
});
if (e.isBuilding) {
stampFootprint(state.nav, rules, e.tx, e.ty, e.fw, e.fh, false);
recomputeEconomyCaps(state, rules);
}
// Anyone building or assisting this is now idle.
for (const o of state.entities) {
if (o.buildTargetId === e.id) o.buildTargetId = 0;
if (o.targetId === e.id) o.targetId = 0;
}
if (def.deathExplosion) {
const ex = def.deathExplosion;
state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius });
for (const o of state.entities) {
if (o.dead || o.id === e.id) continue;
const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius;
if (d > ex.radius) continue;
const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7;
applyDamage(state, rules, o, ex.damage * scale, { army: -1, id: 0 });
}
}
}
// ---------------------------------------------------------------------------
// Vision
// ---------------------------------------------------------------------------
/** Coarse 2-tile vision grid. Recomputed every 4th tick — it does not need to be exact. */
export function computeVision(state, rules) {
const cell = state.tileSize * 2;
for (const a of state.armies) a.visible.fill(0);
for (const e of state.entities) {
if (e.dead) continue;
const a = state.armies[e.army];
if (!a) continue;
const def = defOf(rules, e);
const sight = e.site ? Math.max(64, (def.sight ?? 0) * 0.4) : (def.sight ?? 0);
if (sight <= 0) continue;
const r = Math.ceil(sight / cell);
const cx = Math.floor(e.x / cell), cy = Math.floor(e.y / cell);
for (let y = cy - r; y <= cy + r; y++) {
if (y < 0 || y >= state.visH) continue;
for (let x = cx - r; x <= cx + r; x++) {
if (x < 0 || x >= state.visW) continue;
const wx = x * cell + cell / 2, wy = y * cell + cell / 2;
if (Math.hypot(wx - e.x, wy - e.y) > sight) continue;
const i = y * state.visW + x;
a.visible[i] = 1;
a.explored[i] = 1;
}
}
}
}
/** Is `e` currently visible to `armyIdx`? The AI is held to this, same as the player. */
export function isVisibleTo(state, armyIdx, e) {
if (e.army === armyIdx) return true;
const a = state.armies[armyIdx];
if (!a) return false;
const cell = state.tileSize * 2;
const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell);
if (x < 0 || y < 0 || x >= state.visW || y >= state.visH) return false;
return a.visible[y * state.visW + x] === 1;
}
// ---------------------------------------------------------------------------
// Victory
// ---------------------------------------------------------------------------
function checkResult(state, rules) {
if (state.over) return;
// Default: the Commander IS the army. A single lucky raid can therefore decide a match,
// which is the classic TA bargain — the Commander is a walking win condition, so escorting
// it and hunting theirs is the game. 'annihilation' trades that for the longer match, where
// elimination means "can never produce again" and economy and army quality decide it.
const commanderEnds = state.victory !== 'annihilation';
const unrecoverableEnds = rules.constants.eliminateWhenUnrecoverable !== false;
for (const a of state.armies) {
if (!a.alive) continue;
let hasAny = false, hasBuilder = false, hasFactory = false, hasCommander = false;
for (const e of state.entities) {
if (e.dead || e.army !== a.idx || e.site) continue;
hasAny = true;
const def = rules.defById[e.defId];
if (def.isCommander) hasCommander = true;
if (!(def.builds ?? []).length) continue;
if (e.isBuilding) hasFactory = true; else hasBuilder = true;
}
const lostCommander = commanderEnds && a.hadCommander && !hasCommander;
// An army that still owns a factory can rebuild, so it stays in; one with neither a mobile
// builder nor a factory is only running down the clock.
const unrecoverable = unrecoverableEnds && !hasBuilder && !hasFactory;
if (!hasAny || lostCommander || unrecoverable) {
a.alive = false;
state.events.push({
t: 'armyEliminated',
army: a.idx,
reason: !hasAny ? 'wiped' : (lostCommander ? 'commanderLost' : 'unrecoverable'),
});
}
}
const alive = state.armies.filter((a) => a.alive);
if (alive.length <= 1) {
state.over = { winner: alive.length ? alive[0].idx : -1, tick: state.tick };
state.events.push({ t: 'gameOver', winner: state.over.winner });
}
}
// ---------------------------------------------------------------------------
// The tick
// ---------------------------------------------------------------------------
/** Advance the simulation one fixed step. Returns this tick's events. */
export function tick(state, rules) {
state.events = [];
if (state.over) return state.events;
state.tick++;
state.elapsedSec = state.tick * rules.dt;
stepEconomy(state, rules);
stepOrders(state, rules);
servicePathQueue(state, rules);
stepMovement(state, rules);
stepSeparation(state, rules);
finalizeStuck(state, rules);
stepCombat(state, rules);
stepProjectiles(state, rules);
stepRegen(state, rules);
if (state.tick % 4 === 0) computeVision(state, rules);
// Reap the dead once per tick so nothing iterates a corpse mid-phase.
if (state.entities.some((e) => e.dead)) {
state.entities = state.entities.filter((e) => !e.dead);
}
checkResult(state, rules);
return state.events;
}
/**
* Drive the sim from a wall-clock delta, draining whole fixed steps. The accumulator lives
* in state so the verify script runs the identical loop headlessly, and `state.alpha` gives
* the view its interpolation factor between the last two ticks.
*/
/**
* @param {function} [onTick] runs just BEFORE each fixed step — where the AI belongs, so its
* think cadence is measured in sim ticks rather than riding on the render framerate.
*/
export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) {
const out = [];
state.accumulatorMs += deltaMs;
let n = 0;
while (state.accumulatorMs >= rules.stepMs && n < maxSteps) {
state.accumulatorMs -= rules.stepMs;
if (onTick) onTick(state);
const ev = tick(state, rules);
for (let i = 0; i < ev.length; i++) out.push(ev[i]);
n++;
}
// Spiral-of-death guard: if we hit the cap, drop the backlog rather than fall further behind.
if (n === maxSteps && state.accumulatorMs >= rules.stepMs) state.accumulatorMs = 0;
state.alpha = Math.min(1, state.accumulatorMs / rules.stepMs);
return out;
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/** RLE the terrain — a 128x128 map compresses roughly 30:1, which matters because
* localStorage is a ~5MB budget shared across every game in this repo. */
function rleEncode(arr) {
const out = [];
let run = 1;
for (let i = 1; i <= arr.length; i++) {
if (i < arr.length && arr[i] === arr[i - 1] && run < 65535) { run++; continue; }
out.push(arr[i - 1], run);
run = 1;
}
return out;
}
function rleDecode(list, len) {
const arr = new Uint8Array(len);
let p = 0;
for (let i = 0; i < list.length; i += 2) {
const v = list[i], n = list[i + 1];
for (let k = 0; k < n && p < len; k++) arr[p++] = v;
}
return arr;
}
export function serialize(state) {
return JSON.stringify({
v: SAVE_VERSION,
rulesVersion: state.rulesVersion,
seed: state.seed, rngState: state.rngState,
tick: state.tick, w: state.w, h: state.h, theme: state.theme,
terrain: rleEncode(state.terrain),
victory: state.victory,
over: state.over,
nextId: state.nextId,
armies: state.armies.map((a) => ({
armyId: a.armyId, commanderId: a.commanderId, isHuman: a.isHuman,
aiSkill: a.aiSkill, aiProfile: a.aiProfile,
energy: a.energy, mass: a.mass, alive: a.alive, hadCommander: a.hadCommander,
builtEver: a.builtEver, lostEver: a.lostEver, killsEver: a.killsEver,
explored: rleEncode(a.explored),
})),
entities: state.entities.map((e) => ({
id: e.id, army: e.army, defId: e.defId, isBuilding: e.isBuilding,
x: Math.round(e.x * 8) / 8, y: Math.round(e.y * 8) / 8,
heading: Math.round(e.heading * 1000) / 1000,
turretRot: Math.round(e.turretRot * 1000) / 1000,
hp: Math.round(e.hp * 100) / 100,
site: e.site, progress: Math.round(e.progress * 1000) / 1000,
tx: e.tx, ty: e.ty, fw: e.fw, fh: e.fh,
orders: e.orders, queue: e.queue, jobProgress: Math.round(e.jobProgress * 1000) / 1000,
rallyX: e.rallyX, rallyY: e.rallyY, hasRally: e.hasRally,
targetId: e.targetId, buildTargetId: e.buildTargetId,
lastDamagedTick: e.lastDamagedTick,
reload: e.reload, burstLeft: e.burstLeft,
terrainBonus: e.terrainBonus,
})),
// Projectiles are sub-second transients; dropping them costs nothing and saves bytes.
});
}
export function deserialize(rules, raw) {
let data;
try { data = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; }
if (!data || data.v !== SAVE_VERSION) return null;
if (data.rulesVersion !== rules.version) return null;
const terrain = rleDecode(data.terrain, data.w * data.h);
const state = createMatch(rules, {
seed: data.seed,
// A save written before victory conditions were configurable was played under the
// annihilation rule, so that is what it resumes under — loading it as a Commander-kill
// match could end it on the spot over a Commander that died an hour ago.
victory: data.victory ?? 'annihilation',
map: { w: data.w, h: data.h, terrain, theme: data.theme, starts: [] },
armies: data.armies.map((a) => ({
armyId: a.armyId, commanderId: a.commanderId, isHuman: a.isHuman,
aiSkill: a.aiSkill, aiProfile: a.aiProfile,
})),
});
state.rngState = data.rngState;
state.tick = data.tick;
state.elapsedSec = data.tick * rules.dt;
state.over = data.over;
state.nextId = data.nextId;
state.entities = [];
state.nav.blocked.fill(0);
data.armies.forEach((a, i) => {
Object.assign(state.armies[i], {
energy: a.energy, mass: a.mass, alive: a.alive, hadCommander: !!a.hadCommander,
builtEver: a.builtEver, lostEver: a.lostEver, killsEver: a.killsEver,
explored: rleDecode(a.explored, state.visW * state.visH),
});
});
for (const s of data.entities) {
const def = rules.defById[s.defId];
if (!def) continue;
const e = baseEntity(state, s.army, def);
Object.assign(e, s);
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
e.radius = def.isBuilding ? def.radius : (def.radius ?? 16);
e.path = null; e.pathIdx = 0; e.wantPath = false;
state.entities.push(e);
if (e.isBuilding) {
stampFootprint(state.nav, rules, e.tx, e.ty, e.fw, e.fh, true);
if (!e.site) {
const bdef = rules.buildingById[e.defId];
if (bdef.produce) {
e.produceE = bdef.produce.energy ?? 0;
e.produceM = (bdef.produce.mass ?? 0) * (e.terrainBonus ?? 1);
}
if (bdef.upkeep) e.upkeepE = bdef.upkeep.energy ?? 0;
if (bdef.storage) { e.storeE = bdef.storage.energy ?? 0; e.storeM = bdef.storage.mass ?? 0; }
}
} else {
const udef = rules.unitById[e.defId];
if (udef.produce) { e.produceE = udef.produce.energy ?? 0; e.produceM = udef.produce.mass ?? 0; }
if (udef.storage) { e.storeE = udef.storage.energy ?? 0; e.storeM = udef.storage.mass ?? 0; }
}
}
state.nextId = data.nextId;
recomputeEconomyCaps(state, rules);
computeVision(state, rules);
return state;
}
/** FNV-1a over the serialized state. Used for determinism and round-trip assertions. */
export function hashState(state) {
const s = serialize(state);
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
// ---------------------------------------------------------------------------
// Queries used by the view, the HUD and the AI
// ---------------------------------------------------------------------------
export function armyEntities(state, armyIdx, includeSites = true) {
return state.entities.filter((e) => !e.dead && e.army === armyIdx && (includeSites || !e.site));
}
export function buildOptionsFor(rules, e) {
const def = rules.defById[e.defId];
return (def.builds ?? []).map((id) => rules.defById[id]);
}
/** Can this army afford `def` right now, out of stored resources? */
export function canAfford(state, armyIdx, def) {
const a = state.armies[armyIdx];
return a.energy >= (def.cost?.energy ?? 0) * 0.25 && a.mass >= (def.cost?.mass ?? 0) * 0.25;
}
export { segmentClear, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY };