Spaces:
Paused
Paused
Claude
Close the guard gaps behind the last three bugs; withdraw one review finding
021c865 unverified Download engine/ai.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 12.3 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/engine/ai.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/engine/ai.js
-
curl -L -o ai.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/engine/ai.js
12.3 kB
| /* ============================================================ | |
| Scripted AI opponent: keep workers on the nearest spendable node, keep | |
| population growing, put up a Barracks once it can afford one, then cycle | |
| through its archetype's unit mix (Skiff/Bastion/Lancer/Breacher β see | |
| entities.js for the rock-paper-scissors relationship) across every | |
| Barracks it owns, and throw its home army at the enemy base in | |
| repeated waves, once each wave is big enough (or the game's dragged on | |
| long enough that it should commit anyway). Along the way it fortifies | |
| with Sentinel Turrets on the approach vector, expands to a second | |
| Command Center when its home ore runs thin, puts up a Refinery, and | |
| researches both upgrades β so its opponent isn't the only side that gets | |
| to use crystals/radioactives, expansions, or defenses. | |
| The AI plays under its own fog of war (state.fogs[owner], never a | |
| hardcoded state.fogAI) β it is NOT omniscient. It keeps one unit out | |
| scouting (updateScout), and its intel-dependent moves are gated by what | |
| it has actually seen: every few units it breaks from the mix to counter | |
| whatever the enemy fields most, but only counting enemy units currently | |
| in its vision (counterToPlayerArmy); and it only mines or expands to | |
| nodes it has discovered β charted surface deposits always, hidden caches | |
| once scouted. The rest of the cycle follows the archetype's own flavor, | |
| so a Rusher doesn't turn into a pure reactive counter-picker. | |
| How aggressively vs. how patiently it plays β worker/army targets, attack | |
| timing, unit mix, how many turrets and barracks, when to expand β all comes | |
| from its controller's `archetype` (see engine/aiArchetypes.js), which is | |
| picked by which planet the match is on. This file just executes whatever | |
| profile it's handed; every Tier 4 field is read with a use-site default so | |
| a legacy profile that predates them still runs. | |
| Layered on top of the archetype is the controller's `strategy` (engine/ | |
| aiStrategy.js) β a player-picked overlay (setup.js "AI Strategy"), orthogonal | |
| to which planet/archetype it's facing: Aggressive presses the attack earlier | |
| and forces the issue in Odyssey; Economic keeps a minimal standing army until | |
| it's actually attacked, then surges; Force Parity continuously matches | |
| whatever it's seen of the enemy's army. "default" (unset) reproduces today's | |
| pure archetype-driven play byte-for-byte β every multiplier defaults to 1 and | |
| every flag to falsy. | |
| TIER 1 SELF-PLAY (tools/selfplay.js β a headless bench, never the shipped | |
| game): runAI(state, dt, owner) drives EITHER side. owner defaults to "ai", | |
| so every pre-existing call site (engine/sim.js's per-tick runAI(state, dt), | |
| every one of the ~26 test files that touch state.ai) gets exactly today's | |
| behavior, unchanged. Passing owner="player" instead drives a SECOND, | |
| independently-configured controller for the human seat β state.playerAi, | |
| null unless a caller has populated it β with its own archetype/strategy/ | |
| difficulty/APM/action budget, reading state.fogs.player (never state.fogAI) | |
| everywhere it looks at the world. state.ai and its exact meaning for owner | |
| "ai" are completely untouched by any of this. See aiCommon.js's | |
| controllerFor/otherOwner for how a phase resolves "my controller" and "my | |
| opponent" from `owner` alone. | |
| This file is the ORCHESTRATOR: runAI runs the think-cycle and threads one | |
| world snapshot (aiContext) through the decision phases, which live in cohesive | |
| sibling modules so no single file is a 1000-line god object β | |
| β’ aiCommon.js β owner resolution + the APM action budget + reserve-aware affordability + builder pick | |
| β’ aiStrategy.js β the player-picked strategy table (Aggressive/Economic/Force Parity) | |
| β’ aiWorkers.js β idle-worker logistics/gather steering + the unit-mix filters | |
| β’ aiMilitary.js β defend/attack waves, focus-fire, the scout, target/mix picks | |
| β’ aiEconomy.js β base build-out, expansion, tech gates, production, research, market barter | |
| β’ aiIndustry.js β the Odyssey factory chain / power / capital path / rig | |
| β’ aiSuperweapon.js β arms/delivers a built Helium Bomb | |
| The phase order in runAI is load-bearing (shared action budget + ore reserves), | |
| so it stays here where the whole sequence reads top-to-bottom. | |
| ============================================================ */ | |
| ; | |
| import { UNITS } from "./entities.js"; | |
| import { playerBuildings, playerUnits } from "./state.js"; | |
| import { accrueActionBudget, controllerFor, otherOwner } from "./aiCommon.js"; | |
| import { assignIdleWorkers } from "./aiWorkers.js"; | |
| import { updateScout, aiMilitary, applyFocusFire, visibleThreatsNearHome } from "./aiMilitary.js"; | |
| import { aiFoundOrSurvive, aiExpand, aiBaseAndTech, aiProduceAndFortify, aiResearch, aiMarketBarter } from "./aiEconomy.js"; | |
| import { aiIndustry, aiIndustryReserve } from "./aiIndustry.js"; | |
| import { aiSuperweapon } from "./aiSuperweapon.js"; | |
| import { strategyFor } from "./aiStrategy.js"; | |
| import { updateIntel, readEnemy, updateAdaptMode } from "./aiIntel.js"; | |
| import { adaptivityFor } from "./aiDifficulty.js"; | |
| // Sim seconds between one controller's think cycles. EXPORTED so a test can drive an AI function | |
| // at the cadence the game really uses instead of hardcoding its own guess β the gap that let | |
| // aiIntel.js's fade ship a decay two hundred times too steep: its tests jumped state.time in one | |
| // hop, which is correct for a single call and says nothing about the dozens runAI actually makes | |
| // between two sightings. test/_helpers.js's advanceThinkCycles is built on this constant so the | |
| // two can never drift apart. | |
| export const THINK_INTERVAL = 1.5; | |
| /** | |
| * @param {State} state | |
| * @param {number} dt | |
| * @param {string} [owner] which side to drive β "ai" (default, today's only configuration) or | |
| * "player" (Tier 1 self-play only, and only once state.playerAi has been populated β see | |
| * tools/selfplay.js). A "player" call with no state.playerAi is a safe, silent no-op. | |
| */ | |
| export function runAI(state, dt, owner = "ai") { | |
| accrueActionBudget(state, dt, owner); // every tick, so credits build up between think cycles | |
| const controller = controllerFor(state, owner); | |
| if (!controller) return; // self-play inert: owner "player" with no state.playerAi configured yet | |
| controller.think = (controller.think || 0) - dt; | |
| if (controller.think > 0) return; | |
| controller.think = THINK_INTERVAL; | |
| // One snapshot of this controller's world, threaded through every decision phase below. Order is | |
| // load-bearing: each phase can spend from a shared per-cycle action budget (canAct/spend), | |
| // and an earlier phase can bank ore (ctx.*Reserve) that a later one must leave alone. | |
| const ctx = aiContext(state, owner); | |
| // Scout + worker assignment run first β a worker turned scout mustn't then be re-tasked to gather. | |
| updateScout(state, ctx, ctx.threats.length > 0); | |
| assignIdleWorkers(state, ctx.workers, ctx.fog); | |
| aiFoundOrSurvive(state, ctx); // Odyssey: (re)seat a razed / opening base from a colony ship | |
| aiExpand(state, ctx); // scout Ranger + found a second base once home ore thins (sets ctx.oreReserve) | |
| aiBaseAndTech(state, ctx); // workers, supply, Barracks, the Foundry/Arsenal tech gates, the Mender | |
| aiIndustryReserve(state, ctx); // Odyssey: bank the grid + bootstrap chain BEFORE production can spend it (sets ctx.industryReserve) | |
| aiProduceAndFortify(state, ctx); // the shared unit-production cycle, Turrets, a 2nd Barracks, Refineries | |
| aiResearch(state, ctx); // one doctrine upgrade per think cycle | |
| aiMarketBarter(state, ctx.owner); // Odyssey, Medium+/Hard only: convert a surplus into this cycle's bottleneck (owner "ai" only β Odyssey/market is out of Tier 1's scope) | |
| aiIndustry(state, ctx); // Odyssey: power the base + electrify it (deeper phases: the factory chain) | |
| aiMilitary(state, ctx); // defend a pressed base, else muster and commit the next wave | |
| aiSuperweapon(state, ctx); // Odyssey: arm/deliver a built Helium Bomb (engine/aiSuperweapon.js) | |
| // TACTICAL micro (opt-in via aiMicro): concentrate the army's fire on one target so kills land | |
| // faster and incoming damage drops sooner β layered on top of the wave logic above without | |
| // touching it. A no-op unless real enemy combat is in sight, so the resolve guarantee holds. | |
| if (controller.micro) applyFocusFire(state, ctx); | |
| } | |
| // Snapshot `owner`'s world once per think cycle: the archetype (+ an `arch` reader that lets its | |
| // Odyssey overlay win), this controller's own units bucketed by role, its buildings and the key | |
| // ones by type, and the enemy combat pressing home β all read off `owner`'s OWN fog | |
| // (state.fogs[owner]), never a hardcoded side. The three *Reserve fields start at 0 and are the | |
| // running ore holdbacks the phases pass forward. See the AiContext typedef in engine/types.js. | |
| /** @param {State} state @param {string} owner @returns {AiContext} */ | |
| function aiContext(state, owner = "ai") { | |
| const controller = controllerFor(state, owner); | |
| const enemyOwner = otherOwner(owner); | |
| const fog = state.fogs[owner]; | |
| const archetype = controller.archetype; | |
| const arch = field => (state.diplomacy && archetype.odyssey && archetype.odyssey[field] != null) | |
| ? archetype.odyssey[field] : archetype[field]; | |
| const strategy = strategyFor(state, owner); | |
| const buildings = playerBuildings(state, owner); | |
| const threats = visibleThreatsNearHome(state, owner); | |
| // WAR FOOTING (engine/aiStrategy.js Economic strategy): the instant this controller can SEE | |
| // combat units pressing its base, timestamp it β then for warFootingTime seconds | |
| // afterward, aiEconomy's standing-army throttle treats it as "recently attacked" | |
| // and lifts its cap, so being attacked is what turns a minimal economy into a real | |
| // production push. Updated here (not left to a later phase) so it's consistent for | |
| // every phase reading ctx.warFooting this same cycle. A strategy without | |
| // warFootingTime (every strategy but Economic) reads warFooting as permanently false. | |
| if (threats.length > 0) controller.lastThreatAt = state.time; | |
| const warFooting = !!strategy.warFootingTime | |
| && controller.lastThreatAt != null | |
| && (state.time - controller.lastThreatAt) < strategy.warFootingTime; | |
| // OPPONENT BELIEF (engine/aiIntel.js): fold this cycle's sighting into the persistent read BEFORE | |
| // any phase consults it, exactly like warFooting above β so every phase in one cycle reasons | |
| // about the same picture of the enemy rather than each re-deriving it at a different moment. | |
| updateIntel(state, owner); | |
| // β¦then step the damped STANCE off that belief (aiIntel.js updateAdaptMode). Order matters: the | |
| // stance must be derived from THIS cycle's sighting, not last cycle's. adaptivity 0 (Easy) pins | |
| // it at neutral, which makes every consumer below byte-identical to having no stance at all. | |
| updateAdaptMode(state, owner, adaptivityFor(state, owner)); | |
| return { | |
| owner, enemyOwner, fog, controller, | |
| archetype, arch, strategy, warFooting, | |
| // The read itself, snapshotted once per cycle. `posture` is null until something has actually | |
| // been seen β a consumer must treat that as "I don't know", never as "they are peaceful". | |
| enemy: readEnemy(state, owner), | |
| ai: state.players[owner], | |
| workers: playerUnits(state, owner).filter(u => u.type === "worker"), | |
| army: playerUnits(state, owner).filter(u => UNITS[u.type].role === "combat"), | |
| rangers: playerUnits(state, owner).filter(u => u.type === "ranger"), | |
| bombs: playerUnits(state, owner).filter(u => u.type === "heliumbomb"), | |
| buildings, | |
| cc: buildings.find(b => b.type === "command" && !b.constructing), | |
| colonyShip: state.endless ? (playerUnits(state, owner).find(u => u.type === "colonyship") || null) : null, | |
| barracks: buildings.find(b => b.type === "barracks"), | |
| refinery: buildings.find(b => b.type === "refinery"), | |
| allBarracks: buildings.filter(b => b.type === "barracks"), | |
| threats, | |
| oreReserve: 0, foundryReserve: 0, refineryReserve: 0, industryReserve: 0, | |
| }; | |
| } | |