// 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 }; } /** Auto-heal priority: lower tier heals first — defensive > factory > other building > unit. */ function healPriorityTier(def) { if (!def.isBuilding) return 3; if (def.weapons?.length) return 0; if (def.builds?.length) return 1; return 2; } /** * Best in-range damaged ally for `e` to auto-repair, or null. Same reach math as a player's * own repair order (`buildRange` + target radius) — a site under construction is `assist`'s * job, not this one, so it's excluded even though its partial hp technically reads as damaged. */ function findAutoHealTarget(state, rules, e, def) { const range = def.buildRange ?? 0; let best = null, bestTier = Infinity, bestDSq = Infinity; for (const t of state.entities) { if (t.dead || t.id === e.id || t.army !== e.army || t.site) continue; if (t.hp >= t.maxHp) continue; const reach = range + t.radius; const dSq = (t.x - e.x) ** 2 + (t.y - e.y) ** 2; if (dSq > reach * reach) continue; const tier = healPriorityTier(defOf(rules, t)); if (tier < bestTier || (tier === bestTier && dSq < bestDSq)) { best = t; bestTier = tier; bestDSq = dSq; } } return best; } 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; // Auto-heal: an idle builder with something damaged in range starts repairing it on its // own, no order needed. `auto: true` marks it as self-assigned rather than player-issued, // so a real order the player gives afterward — pushOrder always replaces e.orders wholesale // when not queuing — simply overwrites it like any other order, never fights it. if (def.buildPower > 0) { const target = findAutoHealTarget(state, rules, e, def); if (target) e.orders.push({ type: 'repair', targetId: target.id, auto: true }); } 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; } // Auto-heal keeps re-prioritising while it works, on the same staggered cadence combat // uses for target acquisition — a defensive building taking fire should pull a builder off // a generator it's already patching, but two similarly-urgent targets shouldn't make it // flicker between them every time this runs. if (order.type === 'repair' && order.auto && def.buildPower > 0) { const due = ((state.tick + e.id) % rules.constants.retargetPeriodTicks) === 0; if (due) { const current = entityById(state, order.targetId); const currentValid = !!current && !current.site && current.hp < current.maxHp && Math.hypot(current.x - e.x, current.y - e.y) <= (def.buildRange ?? 0) + current.radius; const currentTier = currentValid ? healPriorityTier(defOf(rules, current)) : Infinity; const candidate = findAutoHealTarget(state, rules, e, def); if (candidate && (!currentValid || healPriorityTier(defOf(rules, candidate)) < currentTier)) { order.targetId = candidate.id; } } } 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 };