SpaceCities / engine /sim.js
Claude
Claude Sonnet 5
Add the rival Gate: a neighbour races its own Antimatter Gate
f75beff unverified
Raw History Blame Contribute Delete
24.9 kB
/* ============================================================
Per-tick orchestrator: advances every unit and building by one fixed
timestep, runs the AI's think cycle, and checks for a winner. This is
the only thing engine/loop.js's `update` callback calls.
============================================================ */
"use strict";
import { stepToward, keepEscortStation, keepFormationStation, keepFollowingLeader, orderedSpeed } from "./movement.js";
import { buildUnitGrid } from "./grid.js";
import { updateGather } from "./gather.js";
import { updateHaul, assignHaul, updateService, assignService, updateFerry, assignFerry, assignShuttle, updateFreighterShuttle, countLogistics, payAIUpkeep, FREIGHTER_AI_TECH } from "./haul.js";
import { updateScoutMode } from "./scout.js";
import { updateRepair, updateBulwarkRegen, pickRepairTarget, assignRepair, updateRepairJob, countRepairJobs, HEALED } from "./repair.js";
import { updateCombat, updateBuildingCombat, updateWorkerCombat } from "./combat.js";
import { updateBombFuse, updateCraters } from "./bomb.js";
import { updateWreckage } from "./wreckage.js";
import { updateBuildingConstruction, updateProductionQueue, BUILD_REACH } from "./production.js";
import { updateUnitRecycle, updateBuildingRecycle } from "./recycle.js";
import { updateProduction, updateCombustors } from "./industry.js";
import { updatePlasmaRig } from "./rig.js";
import { updateResearch } from "./techtree.js";
import { updateWonder } from "./wonder.js";
import { applySeparation } from "./separation.js";
import { updateFog } from "./fog.js";
import { UNITS, BUILDINGS, canLogisticsType } from "./entities.js";
import { getEntity } from "./state.js";
import { checkWinCondition, checkEndlessLoss, checkEndlessWin } from "./victory.js";
import { runAI } from "./ai.js";
import { updateScenario } from "./scenarios.js";
import { updateMarket } from "./market.js";
import { updateDiplomacy } from "./diplomacy.js";
export function tick(state, dt) {
if (state.over) return;
// Scenario mode drives the convoy/piracy/objective itself; the skirmish
// economy AI only runs in a normal match (see engine/scenarios.js).
if (state.scenario) updateScenario(state, dt);
else runAI(state, dt);
// Broad-phase spatial index for this tick, shared by movement avoidance,
// combat acquisition, and the separation pass below (see engine/grid.js).
state.unitGrid = buildUnitGrid(state);
// Per-node miner count for this tick, read by gather.js's saturation falloff.
// Frozen before any worker mines so every miner on a node sees the same count
// regardless of Map iteration order (determinism).
countMiners(state);
// Per-building hauler/supplier counts for this tick, frozen before any idle worker is
// assigned a logistics job below β€” so the "≀2 per building" caps read the same regardless
// of Map iteration order (determinism). A no-op in skirmish: nothing there has a buffer.
countLogistics(state);
// Settle the fuel-burning Generators' Power BEFORE any consumer reads powerCap this tick,
// so the grid a factory/rig sees is stable. A no-op without a Combustion Generator.
updateCombustors(state, dt);
// Per-building count of auto-repair Menders already committed to it, frozen before any Mender
// re-targets below β€” so they SPREAD OUT (one per building) instead of all piling on the worst one.
countMenderTargets(state);
// Per-building count of workers already assigned to REPAIR it this tick, frozen before any idle
// worker claims a fresh repair job below β€” the ≀MAX_REPAIRERS cap (engine/repair.js), same shape
// as countLogistics above. A no-op wherever nothing is damaged.
countRepairJobs(state);
// Aura projectors (Aegis) for this tick, read by combat.js attackDamage. Collected
// once here (a tiny list β€” the units are Tier-3 and rare) so a landed hit costs O(anvils)
// not O(units); positions are frozen at tick start like the grid above (deterministic).
collectAnvils(state);
for (const unit of state.units.values()) updateUnit(state, unit, dt);
applySeparation(state, dt);
updateFog(state, state.fog, "player");
updateFog(state, state.fogAI, "ai"); // the AI sees only what its own units/buildings reveal, same as the player
for (const building of state.buildings.values()) {
updateBuildingConstruction(state, building, dt);
updateProductionQueue(state, building, dt);
updateProduction(state, building, dt); // Odyssey factories refine raw hauls into goods (no-op without a recipe)
updatePlasmaRig(state, building, dt); // Odyssey Plasma Rig digs raw materials from the core (no-op without a rig def)
updateResearch(state, building, dt); // Odyssey Datacenter develops the tech tree (no-op for any other building)
updateWonder(state, building, dt); // Odyssey Antimatter Gate charges toward the galaxy win (no-op off a wonder)
if (state.endless && !state.background) updateDecay(building, dt); // Odyssey structures wear out if not mended
updateBuildingCombat(state, building, dt);
updateBuildingRecycle(state, building, dt); // last: may remove the building this tick (engine/commands.js issueRecycle)
}
// Healing runs last, once every combatant and building has taken its damage
// for the tick, so a Mender patches the freshest wounds (see repair.js).
updateRepair(state, dt);
// Bulwark doctrine regen (engine/repair.js) β€” same "after this tick's combat" timing as the
// Mender pass above, so a unit hit THIS tick (lastHitAt just stamped to the still-current
// state.time, below) never regens on that same tick. A no-op for any player without the
// doctrine researched, so games without it stay byte-identical.
updateBulwarkRegen(state, dt);
// Scenario mode settles its own win/lose inside updateScenario; Odyssey
// (endless) only ends on losing the player's Command Center; a normal match
// uses the CC/score victory check.
if (state.scenario) { /* updateScenario already set state.over if finished */ }
// checkEndlessWin runs on EVERY endless world, background included β€” a rival Gate (Phase 7)
// usually completes on a colony the player has left, not the active seat, and its AI-owned
// branch only ever pushes a galaxy event (never finish()), so running it in the background is
// harmless there. checkEndlessLoss stays active-world-only as before: it already no-ops for
// every galaxy world via state.inGalaxy, so this scoping only ever matters to a standalone
// endless test fixture, and a colony (background) is never "over" regardless.
else if (state.endless) { checkEndlessWin(state); if (!state.background) checkEndlessLoss(state); }
else checkWinCondition(state);
if (state.market) updateMarket(state, dt); // Odyssey: relax trade pressure back toward equilibrium
if (state.diplomacy) updateDiplomacy(state, dt); // Odyssey: drift the neighbour's stance with scarcity
state.time += dt;
state.tick++;
// A Helium Bomb crater (engine/bomb.js) matures on its own schedule, independent of any
// unit/building β€” checked once per tick against the just-advanced state.time.
updateCraters(state);
// Battle wreckage (engine/wreckage.js) matures the same way, on its own schedule.
updateWreckage(state);
}
// Collect this tick's aura projectors β€” units with a guardAura (the Aegis) AND buildings with one
// (the Aegis Bastion, entities.js) β€” into a flat list on the state, so combat.js can look up "is
// this target inside a friendly aura" against a handful of entries instead of scanning every unit
// per hit. Transient (never serialized), rebuilt each tick from live positions β€” deterministic.
// Exported so combat-only test harnesses (which drive updateCombat directly, bypassing tick) can
// build the same list before a fight.
export function collectAnvils(state) {
const out = [];
for (const u of state.units.values()) {
const g = UNITS[u.type]?.guardAura;
if (g && u.hp > 0) out.push({ id: u.id, owner: u.owner, x: u.x, y: u.y, range: g.range, mult: g.damageTakenMult });
}
// The static guard-aura projector: same {id,owner,x,y,range,mult} shape as the units pass
// above, so combat.js's anvilAura (already target-kind-agnostic β€” it reads target.owner/id,
// never target.kind) needs zero changes to pick these up too. Skips a still-constructing one
// (no aura until it's actually finished standing).
for (const b of state.buildings.values()) {
const g = BUILDINGS[b.type]?.guardAura;
if (g && b.hp > 0 && !b.constructing) out.push({ id: b.id, owner: b.owner, x: b.x, y: b.y, range: g.range, mult: g.damageTakenMult });
}
state.anvils = out;
}
// Tally how many workers (both sides) are assigned to gather each node this
// tick β€” the single source of truth for gather.js's saturation efficiency.
// Counts every worker on a `gather` order, not just those physically at the
// rock, so the "~3 workers per node" rule reads by intent rather than by who
// happens to be mid-haul. Recomputed from scratch each tick β€” never accumulates.
function countMiners(state) {
for (const n of state.map.nodes) n.miners = 0;
for (const u of state.units.values()) {
const o = u.order;
if (o && o.type === "gather") {
const n = state.map.nodesById ? state.map.nodesById.get(o.nodeId) : null;
if (n) n.miners++;
}
}
}
function updateUnit(state, unit, dt) {
// Whenever the active order finishes (arrival, target killed, node drained),
// pull in the next queued waypoint before anything else runs this tick
// β€” so a completed step flows straight into the next, and a combat unit only
// falls back to auto-acquiring once its whole chain is exhausted.
if (!unit.order && unit.orderQueue && unit.orderQueue.length) {
unit.order = unit.orderQueue.shift();
// A patrol leg (engine/commands.js issuePatrol) loops forever: the instant it's pulled off
// the queue and made active, requeue it at the tail too, so once THIS leg completes and the
// next one is pulled, this one is already waiting to come back around. The very FIRST leg of
// a fresh patrol never passes through here at all (issuePatrol places it directly into
// `order`, same as any other plain command) β€” issuePatrol gives it the equivalent trailing
// copy by hand for exactly that reason, so every leg ends up with one "next occurrence"
// queued behind it and the whole chain cycles instead of draining to idle.
if (unit.order.patrol) unit.orderQueue.push(unit.order);
}
const def = UNITS[unit.type];
// Belt-and-suspenders: persist.js's cleanEntity already strips any unit with an
// unknown type before it reaches state.units on load (test/save-hardening.test.js),
// so def is always populated today. This just guards a future entity source that
// might bypass that path, matching the same "if (!def) skip" style repair.js uses
// for mender defs above.
if (!def) return;
// A unit the player is Recycling (engine/commands.js issueRecycle) is on its own countdown,
// checked before anything else this tick β€” same "takes no further orders" precedent as the bomb
// fuse right below, and before the role dispatch so EVERY role (worker, combat, support, a
// freighter, …) is covered by the one check rather than needing it threaded into each branch.
if (updateUnitRecycle(state, unit, dt)) return;
if (def.role === "combat") { updateCombat(state, unit, dt); return; }
if (def.role === "support") { updateSupport(state, unit, def, dt); return; }
// An ARMED Helium Bomb lights its fuse the instant a live enemy comes within
// range (or the player triggers it directly) and detonates BOMB_FUSE_DELAY
// sim-seconds later β€” checked before anything else this tick. Once fused it
// takes no further orders; once it actually goes off, there's nothing left
// to update (it's no longer in state.units).
if (def.role === "bomb" && updateBombFuse(state, unit)) return;
// A COLLECTION-POINT freighter (`unit.collectPoint`, HUD toggle β€” no research needed) that's
// idle and full offers itself a SHUTTLE run: drive to the Command Center, bank the whole hold,
// drive back to its anchor (engine/haul.js assignShuttle/updateFreighterShuttle). Checked before
// the AI-logistics offer below so a full collection-point freighter always empties itself first.
if (!unit.order && def.role === "freighter" && unit.owner === "player" && unit.collectPoint) {
assignShuttle(state, unit);
}
// Toggling collection-point mode off mid-run stands the freighter down immediately, same as
// AI-logistics below β€” whatever's in its hold just sits there until reassigned.
if (unit.order && unit.order.type === "shuttle" && !unit.collectPoint) { unit.order = null; }
// An idle Odyssey worker with nothing queued offers itself for logistics: FIRST it offers to
// ferry a nearby COLLECTION-POINT freighter (`assignFerry`) β€” checked ahead of haul/service
// because ferry and haul share the SAME per-producer ≀MAX_HAULERS cap (engine/haul.js
// countLogistics). If plain CC-bound haulers got first refusal, a busy rig's two slots would
// always go to them as workers cycle through, and a collection-point freighter parked right next
// to it would never actually get fed β€” exactly the "workers don't see it" gap this priority
// avoids. THEN it clears a pure producer's backed-up output to a Command Center (haul β€” the Rig,
// the drop-offs), else it runs a factory a round-trip service (carry inputs in, output back).
// Player-only β€” the AI builds no producers/factories, so its workers never do this and its
// replay is unchanged.
//
// A freighter the player has toggled into AI-logistics mode (`aiLogistics`, HUD button β€” requires
// the FREIGHTER_AI_TECH research) offers itself the haul/service way too (never ferry β€” a
// freighter doesn't ferry another freighter), at its own far larger cargo capacity (engine/
// haul.js tripCapacity) β€” the order it lands is stamped `aiJob` so the upkeep check below knows
// to bill it. Regular freighters (aiLogistics off) are untouched β€” fully player-controlled, same
// as before. Claiming a NEW job also requires at least SOME AI Cores in stock: a freighter that
// can't afford to move shouldn't still reserve one of a producer's scarce ≀MAX_HAULERS slots
// doing nothing β€” an already-running job is a separate case, handled by the upkeep check below,
// which pauses it in place rather than dropping it the instant the treasury dips dry.
const aiFreighter = def.role === "freighter" && unit.owner === "player" && unit.aiLogistics
&& !!state.players[unit.owner].upgrades[FREIGHTER_AI_TECH]
&& (state.players[unit.owner].resources.ai || 0) > 0;
if (!unit.order && canLogisticsType(unit.type) && unit.owner === "player") {
assignFerry(state, unit);
}
if (!unit.order && (canLogisticsType(unit.type) || aiFreighter) && unit.owner === "player") {
assignHaul(state, unit);
if (!unit.order) assignService(state, unit);
if (unit.order && aiFreighter) unit.order.aiJob = true;
}
// Still nothing to do: a worker (not a freighter β€” patching a hull isn't a cargo run) offers
// itself to REPAIR the own damaged building most in need, zone-first like haul/service (engine/
// repair.js assignRepair). Checked last so an economy job always outranks a repair, matching the
// ferry > haul > service priority above β€” a stalled factory needs labour more urgently than a
// building that's merely below full HP.
if (!unit.order && canLogisticsType(unit.type) && unit.owner === "player") {
assignRepair(state, unit);
}
if (!unit.order) return;
// An autonomous freighter's job burns AI Cores from the treasury every tick it's active
// (engine/haul.js payAIUpkeep). Toggling AI-logistics off mid-job stands it down immediately (its
// cargo, if any, just sits aboard until reassigned); running dry of AI Cores pauses it in place
// for the tick β€” nothing lost, it resumes the moment the treasury can cover it again. A plain
// worker's order never carries `aiJob`, so this is a no-op for the whole worker roster.
if (unit.order.aiJob) {
if (!unit.aiLogistics || !state.players[unit.owner].upgrades[FREIGHTER_AI_TECH]) { unit.order = null; return; }
if (!payAIUpkeep(state, unit, dt)) return;
}
switch (unit.order.type) {
case "move":
// A non-combat/non-support role (today: only "scout", the Ranger) reaches an "attack-move"
// order here rather than through combat.js's updateCombat (role "combat" only) β€” the patrol
// order (engine/commands.js issuePatrol) is gated to combat/scout roles alike, and a scout
// never auto-acquires regardless of order type (that's entirely role-gated, in updateCombat),
// so for it "attack-move" just means "get there", identically to a plain move.
case "attack-move": {
const arrived = stepToward(state, unit, unit.order.x, unit.order.y, orderedSpeed(def.speed, unit.order), dt);
if (arrived) unit.order = null;
break;
}
case "gather":
updateGather(state, unit, dt);
break;
case "haul":
updateHaul(state, unit, dt);
break;
case "service":
updateService(state, unit, dt);
break;
case "ferry":
updateFerry(state, unit, dt);
break;
case "repair":
updateRepairJob(state, unit, dt);
break;
case "shuttle":
updateFreighterShuttle(state, unit, dt);
break;
case "escort":
// A non-combat escort (worker) just keeps station on the ring around the guarded ship.
keepEscortStation(state, unit, def.speed, dt);
break;
case "hold-formation":
// A non-combat formation LEADER (worker, freighter) holds its own fixed anchor.
keepFormationStation(state, unit, def.speed, dt);
break;
case "follow-leader":
// A non-combat formation FOLLOWER just keeps its slot relative to the leader.
keepFollowingLeader(state, unit, def.speed, dt);
break;
case "scout":
updateScoutMode(state, unit, dt);
break;
case "attack":
// Workers only reach here on an explicit attack order (combat units are
// handled by updateCombat above); they close in and fight weakly.
updateWorkerCombat(state, unit, def, dt);
break;
case "build": {
const b = state.buildings.get(unit.order.buildingId);
if (!b) { unit.order = null; break; }
const dist = Math.hypot(b.x - unit.x, b.y - unit.y);
if (dist > BUILD_REACH) stepToward(state, unit, b.x, b.y, def.speed, dt);
else if (!b.constructing) unit.order = null;
break;
}
// Any order type this non-combat/non-support unit can't act on (e.g. a "gather"
// order that reached a freighter) is dropped rather than left to stick forever
// and wedge the unit's whole order queue. No valid caller hits this today.
default:
unit.order = null;
}
}
// Odyssey structures WEAR OUT: a completed building sheds a small fraction of its max HP per second,
// down to a floor (neglect weakens it but never razes it β€” combat still can). A Mender's passive
// repair (repair.js) out-heals this easily, so keeping a mender on the base is real upkeep. Gentle
// and deterministic; only in Odyssey (a skirmish is short and its byte-identical replay is untouched).
const DECAY_FRAC = 0.0006; // HP shed per second as a share of maxHp β€” a gentle, long-game wear (~18 min to the floor)
const DECAY_FLOOR = 0.35; // …never below this share from wear alone
function updateDecay(building, dt) {
if (building.constructing) return;
const floor = building.maxHp * DECAY_FLOOR;
if (building.hp <= floor) return;
building.hp = Math.max(floor, building.hp - building.maxHp * DECAY_FRAC * dt);
}
// Support-role units (the Mender) carry no weapon β€” the actual healing is the
// global updateRepair pass in repair.js. All this does is MOVE them, so the
// player can position the drone: a plain move/attack-move goes to the point;
// an 'attack' order (should one ever be issued β€” input.js won't, since a Mender
// has no attack stat) is reinterpreted as "go to that foe" so the drone can be
// pushed toward a fight to mend the units in it, and it never deals damage.
function updateSupport(state, unit, def, dt) {
const o = unit.order;
// Idle + AUTO-REPAIR on: roam to the nearest damaged friendly so the drone actively maintains
// the base/army instead of sitting still (the passive repair pass heals it once in reach).
if (!o) { if (unit.autoRepair) autoRepairRoam(state, unit, def, dt); return; }
// A support escort (Mender) trails the guarded ship in formation, mending whatever's near it
// via the global repair pass β€” a medic keeping station on the fleet it's protecting.
if (o.type === "escort") {
keepEscortStation(state, unit, def.speed, dt);
return;
}
// Holding a defensive formation: same idea, station-keeping at its own fixed anchor instead
// of a moving target β€” the drone mends whatever's near it (the repair pass) right where it stands.
if (o.type === "hold-formation") {
keepFormationStation(state, unit, def.speed, dt);
return;
}
// Following a formation leader: keeps its slot relative to the leader's LIVE position.
if (o.type === "follow-leader") {
keepFollowingLeader(state, unit, def.speed, dt);
return;
}
let tx, ty, follow = false, speed = def.speed;
if (o.type === "attack") {
const t = getEntity(state, o.targetId);
if (!t || t.hp <= 0) { unit.order = null; return; }
tx = t.x; ty = t.y; follow = true; // keep chasing a moving foe rather than stopping on arrival
} else if (o.type === "move" || o.type === "attack-move") {
tx = o.x; ty = o.y; speed = orderedSpeed(def.speed, o);
} else {
unit.order = null; return; // gather / scout / build are meaningless for a drone
}
const arrived = stepToward(state, unit, tx, ty, speed, dt);
if (arrived && !follow) unit.order = null;
}
// How many auto-repair Menders are already committed to each BUILDING this tick β€” frozen before any
// re-targets, so the "one Mender per building" cap reads the same regardless of Map order. Only
// buildings are capped (the pile-up the player hit); a wounded unit can still draw several menders.
// Transient (stripped on serialize). A no-op when no Mender is in auto-repair mode.
function countMenderTargets(state) {
for (const b of state.buildings.values()) b.menderClaims = 0;
for (const u of state.units.values()) {
if (!u.autoRepair || !u.repairTargetId) continue;
const t = state.buildings.get(u.repairTargetId);
if (t) t.menderClaims = (t.menderClaims || 0) + 1;
}
}
// An auto-repair Mender roams to the friendly that needs it MOST. Hysteresis kills the ping-pong:
// it only gets ATTRACTED to something worn past NEEDS_REPAIR, and once it commits it STAYS on that
// target until it's topped past HEALED β€” so a full building nicked by a hair of wear can't yank the
// drone back and forth. Picking a NEW target (engine/repair.js pickRepairTarget) is zone-first (a
// damaged building/unit in the Mender's own Command Center zone wins over a nearer one belonging
// to another base β€” engine/gather.js zoneFirst) and, within a zone, most-worn-first, distance
// breaking ties. A BUILDING already claimed by another auto-repair Mender is skipped, so the drones
// spread out one per building instead of dogpiling the worst one. Deterministic; drone parks when
// nothing qualifies.
function autoRepairRoam(state, mender, def, dt) {
const range = def.repairRange || 100;
const ownFriendly = e => e && e.owner === mender.owner && e.hp > 0 && !e.constructing;
// Keep the current target while it still needs work (hysteresis) β€” don't drop it for a
// marginally-worse one, and don't re-grab a just-topped-up one on its first hair of decay.
let target = mender.repairTargetId
? (state.buildings.get(mender.repairTargetId) || state.units.get(mender.repairTargetId))
: null;
if (!(ownFriendly(target) && target !== mender && target.hp < target.maxHp * HEALED)) target = null;
if (!target) { // pick the MOST worn eligible friendly below the attract threshold, own zone first
target = pickRepairTarget(state, mender.owner, mender.x, mender.y, {
exclude: mender,
isClaimed: e => e.kind === "building" && (e.menderClaims || 0) >= 1, // one Mender per building
homeId: mender.homeCC,
});
if (target && target.kind === "building") target.menderClaims = (target.menderClaims || 0) + 1; // claim it for the rest of this tick
}
mender.repairTargetId = target ? target.id : null;
if (target && Math.hypot(target.x - mender.x, target.y - mender.y) > range * 0.7)
stepToward(state, mender, target.x, target.y, def.speed, dt);
}