Spaces:
Paused
Paused
Download engine/entities.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 70 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/engine/entities.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/engine/entities.js
-
curl -L -o entities.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/engine/entities.js
70 kB
| /* ============================================================ | |
| Unit & building definitions for the first skirmish slice. | |
| Costs are in the planet's own deposit commodities (see engine/map.js) | |
| so the resources you gather are the resources you spend β no separate | |
| RTS-only currency. | |
| ============================================================ */ | |
| ; | |
| import { RECIPES } from "../data.js"; | |
| export const BUILDINGS = { | |
| command: { | |
| id: "command", name: "Command Center", hp: 1000, radius: 26, | |
| cost: { ore: 400 }, buildTime: 30, // steep + slow: an expansion is a long-game commitment | |
| // The starting CC is still seeded finished by state.js's seedPlayer β | |
| // makeBuilding without { constructing: true } spawns complete regardless | |
| // of buildTime. cost/buildTime only gate the issueBuild path. | |
| produces: ["worker", "ranger", "colonyship", "hauler", "heavyhauler", "bulkfreighter"], // the Odyssey-only ones are gated by queueProduction (def.odysseyOnly) | |
| isCommandCenter: true, | |
| supplyGrants: 10, // the seeded CC already houses the starting 3 workers with room to grow | |
| sight: 220, | |
| category: "infrastructure", | |
| }, | |
| barracks: { | |
| id: "barracks", name: "Barracks", hp: 500, radius: 20, | |
| cost: { ore: 150 }, buildTime: 20, | |
| produces: ["skiff", "bastion", "lancer", "breacher", "dreadnought", "mender", "wraith", "aegis", "colossus"], | |
| sight: 150, | |
| category: "military", | |
| }, | |
| refinery: { | |
| id: "refinery", name: "Refinery", hp: 400, radius: 18, | |
| cost: { ore: 200 }, buildTime: 16, | |
| sight: 140, | |
| // No `produces` β it researches upgrades instead of building units, | |
| // which is also what keeps it out of the rally-point UI (input.js | |
| // only offers that for buildings with a `produces` list). | |
| category: "industrial", | |
| }, | |
| turret: { | |
| id: "turret", name: "Sentinel Turret", hp: 350, radius: 12, | |
| cost: { ore: 150, crystals: 100 }, buildTime: 12, | |
| sight: 170, | |
| // Static defense: same combat stat names units use, so combat.js's | |
| // acquireTarget/attackDamage apply verbatim. aggroRange === range on | |
| // purpose β a turret can't chase, so acquiring beyond range is useless. | |
| attack: 20, range: 130, cooldown: 1, aggroRange: 130, | |
| category: "military", | |
| }, | |
| // ---- Static-defense ladder, tiers 2-3 (docs/improvement-proposals.md). One turret type used | |
| // to serve all four tiers; these two round it out into a real ladder, each rationalized against | |
| // the SAME invariant: every siege unit (Breacher 150, Colossus 185, Leviathan 200) must still | |
| // strictly outrange every static defense structure here β static defense holds ground by hp and | |
| // attrition, never by out-ranging siege. See the Torpedo Battery below (Strategic tier) for the | |
| // one deliberate, documented narrowing of that rule. | |
| bastille: { | |
| id: "bastille", name: "Bastille", hp: 600, radius: 16, | |
| cost: { ore: 200, crystals: 150, radioactives: 60 }, buildTime: 22, | |
| sight: 150, | |
| // A second, Foundry-gated static-defense tier: a TANKIER, SHORTER-ranged brawler than the | |
| // Sentinel Turret (115 vs 130) β deliberately not a longer-ranged sniper, so the "outranging | |
| // the turret" siege identity (and the cracksBase balance invariant) survives intact even | |
| // though this hits harder and soaks more. Same combat stat field NAMES the turret uses, so | |
| // combat.js's updateBuildingCombat works on it verbatim, zero changes to that function. | |
| // Crystals + radioactives priced (same rare goods an attacker spends on Breachers/doctrines), | |
| // so the defender's fortification investment scales with the same economy the Foundry gates. | |
| attack: 32, range: 115, cooldown: 1.4, aggroRange: 115, | |
| requires: ["foundry"], | |
| category: "military", | |
| }, | |
| aegisbastion: { | |
| id: "aegisbastion", name: "Aegis Bastion", hp: 500, radius: 16, | |
| cost: { ore: 250, crystals: 180 }, buildTime: 26, | |
| sight: 150, | |
| // Arsenal-gated static guard-aura projector: no `attack` of its own β it never fires, and | |
| // combat.js's updateBuildingCombat is already a no-op for any building without one. Instead it | |
| // projects the SAME kind of cryo-armour bubble the Aegis UNIT's guardAura does (see UNITS.aegis | |
| // above): every friendly unit AND building inside `range` takes `damageTakenMult` less damage | |
| // (engine/sim.js collectAnvils' buildings pass feeds engine/combat.js's anvilAura). It defends | |
| // by ATTRITION MATH, never by out-ranging siege β Breacher/Colossus/Leviathan can all still | |
| // shell it (or anything it shields) with impunity from outside its 130-range bubble; it just | |
| // buys the defender time for an army to answer. No `produces`/`supplyGrants`, so | |
| // isElectrifiable already excludes it, same as the Refinery/Foundry/Arsenal above. | |
| guardAura: { range: 130, damageTakenMult: 0.8 }, | |
| requires: ["arsenal"], | |
| category: "military", | |
| }, | |
| habitat: { | |
| id: "habitat", name: "Habitat", hp: 250, radius: 14, | |
| cost: { ore: 75 }, buildTime: 10, | |
| supplyGrants: 8, // ~one economist mix cycle's worth of headroom per dome | |
| sight: 100, | |
| // No `produces` β like the Refinery, this keeps it out of the | |
| // rally-point UI and rally rendering automatically. Softest building | |
| // on the roster (hp 250): a legitimate raid target to choke supply. | |
| category: "infrastructure", | |
| }, | |
| foundry: { | |
| id: "foundry", name: "Foundry", hp: 450, radius: 18, | |
| cost: { ore: 175 }, buildTime: 22, sight: 140, | |
| // The military tech gate: a pure prerequisite building (no `produces`, so | |
| // it stays out of the rally UI). It unlocks the Tier-2 combat units | |
| // (Lancer, Breacher) β the Barracks still trains them. Ore-only on purpose, | |
| // so the Tier-2 units stay reachable on every world (ore is guaranteed near | |
| // every base) rather than being walled off on a crystal-poor map. | |
| requires: ["barracks"], | |
| // Category is "industrial" (not "military") despite gating Tier-2 combat units β | |
| // it groups with the other production-tech buildings in the build menu. | |
| category: "industrial", | |
| // Standing bonus: while a COMPLETED Foundry stands, military unit production runs ~8% | |
| // faster β not just the Lancer/Breacher it gates, so razing it costs an ongoing tempo edge, | |
| // not just access to Tier-2 units. Live-scanned (structureMult below, mirroring | |
| // committedDoctrine's own live scan) β production.js's updateProductionQueue applies it only | |
| // to role:"combat" jobs, so economy production (Workers, Rangers, freighters, β¦) is untouched. | |
| produceTimeMult: 0.92, | |
| }, | |
| arsenal: { | |
| id: "arsenal", name: "Arsenal", hp: 550, radius: 18, | |
| cost: { ore: 220 }, buildTime: 26, sight: 140, | |
| // The Tier-3 gate, one step past the Foundry: unlocks the Dreadnought | |
| // capital unit. Also a pure gate (no `produces`), ore-only so the tech path | |
| // stays reachable on every world. | |
| requires: ["foundry"], | |
| category: "industrial", // same reasoning as the Foundry above | |
| // A standing Arsenal stacks a SECOND ~8% step atop a standing Foundry's β structureMult | |
| // multiplies every standing structure's own field, so both apply together if both stand. | |
| produceTimeMult: 0.92, | |
| }, | |
| spaceport: { | |
| id: "spaceport", name: "Spaceport", hp: 600, radius: 22, | |
| cost: { ore: 300 }, buildTime: 30, sight: 160, | |
| // Odyssey only (offered in the Odyssey build menu): the interplanetary launch | |
| // pad. Selecting a finished Spaceport lets you relocate your single Command | |
| // Center β plus the units staged nearby β to another world (engine/galaxy.js). | |
| // Gated behind the Foundry so leaving the planet is a mid-game milestone. | |
| requires: ["foundry"], | |
| category: "infrastructure", | |
| }, | |
| market: { | |
| id: "market", name: "Market", hp: 150, radius: 12, | |
| cost: { ore: 40 }, buildTime: 6, sight: 90, | |
| // The cheapest, fastest building on the roster β Odyssey's on-ramp into trading, and | |
| // meant to be the first thing a worker raises. A finished Market opens the commodity | |
| // trade panel (hudSelection.js renderMarket) β buy/sell local goods for galaxy credits | |
| // (engine/market.js) β which used to sit on the Command Center; it's moved here so a | |
| // destroyed/absent CC doesn't strand a colony without a market. No `produces`/`recipe`: | |
| // a pure UI gate, so it stays out of the rally-point UI and updateProduction | |
| // (engine/industry.js) skips it like any other non-recipe building. | |
| odysseyOnly: true, | |
| category: "infrastructure", | |
| }, | |
| /* ---- INDUSTRY (Odyssey only) β the production chain that turns raw hauls into | |
| refined goods worth real credits. All ore-costed (ore is guaranteed near every | |
| base, so the chain is reachable on any world) and flagged `odysseyOnly`: they | |
| never appear in a skirmish build menu, the skirmish AI never builds one, and | |
| engine/industry.js's updateProduction is a no-op for any building without a | |
| `recipe` β so a skirmish match never instantiates one and its byte-identical | |
| replay is untouched. A building's `recipe` names an entry in data.js RECIPES; | |
| `prodRate` is how many batches/sec it runs at full power. `energyGrants` is | |
| industrial Power capacity (a per-tick flow, like supply β see engine/industry.js). ---- */ | |
| reactor: { | |
| id: "reactor", name: "Reactor", hp: 400, radius: 16, | |
| cost: { ore: 120 }, buildTime: 16, sight: 130, | |
| energyGrants: 30, // the power that runs the factories below; short power throttles them all | |
| powerRange: 1, // the reference grid reach β a consumer's efficiency tier is its distance / this (industry.js) | |
| // The original power station burns radioactives too β a real fission reactor runs on fissile | |
| // material, not free once built. Its own local fuel larder (entities.js inputCapOf), filled by | |
| // a worker SERVICE run (engine/haul.js), same mechanism as its smaller combust siblings below β | |
| // just at double their burn rate (0.12/s vs 0.06/s), matching its double energyGrants and reach. | |
| // Out of fuel (or paused) it grants nothing, same as them. See engine/industry.js updateCombustors. | |
| combust: { fuels: ["radioactives"], rate: 0.12 }, | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| combustor: { | |
| id: "combustor", name: "Combustion Generator", hp: 300, radius: 13, | |
| cost: { ore: 70 }, buildTime: 12, sight: 110, | |
| // A cheap, fuel-burning alternative to the Reactor: it GRANTS less Power (energyGrants 15 vs 30) | |
| // over a SMALLER grid (powerRange 0.55 shrinks its efficiency zones β industry.js) and burns fuel | |
| // at half the Reactor's rate too β cheaper to raise and run, just less of a grid. It burns gas | |
| // (Helium-3) from its own local fuel buffer β a real WORKER hauls it in from the treasury, same | |
| // as a factory's input larder (engine/haul.js SERVICE), same mechanism the Reactor now uses. Out | |
| // of fuel (or paused) it grants nothing. See engine/industry.js updateCombustors. Its | |
| // biomass-burning sibling is the Biomass Reactor below. | |
| energyGrants: 15, powerRange: 0.55, | |
| combust: { fuels: ["gas"], rate: 0.06 }, | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| biomassreactor: { | |
| id: "biomassreactor", name: "Biomass Reactor", hp: 300, radius: 13, | |
| cost: { ore: 70 }, buildTime: 12, sight: 110, | |
| // The Combustion Generator's sibling, for a claim rich in biomass instead of Helium-3 β same | |
| // cheap-but-fed deal (energyGrants/powerRange/rate all match), just its own single fuel and a | |
| // worker hauling it in the same way (engine/haul.js SERVICE, engine/industry.js updateCombustors). | |
| energyGrants: 15, powerRange: 0.55, | |
| combust: { fuels: ["biomass"], rate: 0.06 }, | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| substation: { | |
| id: "substation", name: "Grid Substation", hp: 180, radius: 11, | |
| cost: { ore: 60, crystals: 30 }, buildTime: 10, sight: 90, | |
| // A passive one-hop RELAY, not a power station β no `energyGrants` (it grants zero Power | |
| // capacity of its own) and no `combust` (it burns no fuel). Every base otherwise collapses | |
| // into one dense blob around its Reactors, because the only remedy for grid tax used to be | |
| // ANOTHER fuel-burning station; this is the cheap alternative: while it stands within the | |
| // 'linked' band of one of the owner's own ACTIVE power sources, it counts as a second, | |
| // shorter-range virtual source point for GRID-TIER purposes (engine/industry.js bestGridDist's | |
| // relay pass) β so a factory too far from the Reactor to be worth powering directly can | |
| // huddle near a cheap relay instead. One hop only: a relay never extends through another | |
| // relay, so the grid-distance scan stays O(sources Γ relays), trivially deterministic. | |
| // `powerRange` here is the relay's OWN (shorter) reach for whoever measures distance TO it β | |
| // unrelated to the reach it itself needs to reach a real source (POWER_TIERS[0], fixed). | |
| powerRelay: true, powerRange: 0.8, | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| smelter: { | |
| id: "smelter", name: "Smelter", hp: 420, radius: 16, | |
| cost: { ore: 160 }, buildTime: 18, sight: 140, | |
| dropOff: true, // an industrial building doubles as a resource drop-off | |
| recipe: "smelt", prodRate: 2, // ore + power β metals (data.js RECIPES.smelt) | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| assembler: { | |
| id: "assembler", name: "Assembly Plant", hp: 440, radius: 17, | |
| cost: { ore: 180 }, buildTime: 20, sight: 140, | |
| dropOff: true, | |
| recipe: "alloy", prodRate: 1.5, // metals + power β alloys (data.js RECIPES.alloy) | |
| // The chain reads as a chain (needs a Smelter for metals) AND is the first | |
| // thing the tech tree gates: Metallurgy research (engine/techtree.js) unlocks | |
| // it, so the free tier (Smelter β metals) gives an immediate payoff while | |
| // deeper refining is an investment. `metallurgy` is a tech-node token that | |
| // prereqsMet resolves out of player.upgrades, exactly like a building token. | |
| requires: ["smelter", "metallurgy"], | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| /* ---- Phase 2 research + deeper industry (Odyssey only). The Datacenter hosts | |
| the tech tree (engine/techtree.js); the Chip Fab and Machine Works are the | |
| next two hops of the chain, each gated behind a research node so unlocking a | |
| node visibly unlocks new production. All ore-costed and odysseyOnly, like the | |
| Phase-1 factories. ---- */ | |
| datacenter: { | |
| id: "datacenter", name: "Datacenter", hp: 420, radius: 17, | |
| cost: { ore: 200 }, buildTime: 22, sight: 140, | |
| odysseyOnly: true, | |
| // Hosts research β no `recipe` (updateProduction ignores it) and no `produces` | |
| // (stays out of the rally UI). Selecting it opens the research panel (hud.js). | |
| category: "infrastructure", | |
| }, | |
| chipfab: { | |
| id: "chipfab", name: "Chip Fab", hp: 440, radius: 17, | |
| cost: { ore: 190 }, buildTime: 20, sight: 140, dropOff: true, | |
| recipe: "chipfab", prodRate: 1.5, // crystals + metals + power β electronics | |
| requires: ["smelter", "electronics"], // needs the metals chain + Microelectronics research | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| machineworks: { | |
| id: "machineworks", name: "Machine Works", hp: 460, radius: 18, | |
| cost: { ore: 220 }, buildTime: 24, sight: 140, dropOff: true, | |
| recipe: "machine", prodRate: 1, // alloys + electronics + power β machinery | |
| requires: ["assembler", "chipfab", "machining"], // the capstone: the whole chain + Precision Machining | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| /* ---- Phase 3: the Strategic tier + the endgame (Odyssey only). The Antimatter | |
| Forge tops the chain (machinery β antimatter), and the Antimatter Gate is the | |
| WONDER that consumes antimatter to WIN the galaxy β Odyssey's first victory | |
| condition (engine/wonder.js + engine/victory.js checkEndlessWin). ---- */ | |
| antimatterforge: { | |
| id: "antimatterforge", name: "Antimatter Forge", hp: 460, radius: 18, | |
| cost: { ore: 240 }, buildTime: 26, sight: 140, dropOff: true, | |
| recipe: "antifab", prodRate: 0.7, // machinery + radioactives + power β antimatter (RTS-recost recipe) | |
| requires: ["machineworks", "antimatter"], // needs the full chain + Antimatter Containment research | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| aifoundry: { | |
| id: "aifoundry", name: "AI Foundry", hp: 460, radius: 18, | |
| cost: { ore: 240 }, buildTime: 26, sight: 140, dropOff: true, | |
| recipe: "aifab", prodRate: 0.7, // electronics + crystals + power β AI Cores | |
| requires: ["chipfab", "aicores"], | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| torpedoworks: { | |
| id: "torpedoworks", name: "Torpedo Works", hp: 460, radius: 18, | |
| cost: { ore: 240 }, buildTime: 26, sight: 140, dropOff: true, | |
| recipe: "plasmafab", prodRate: 0.7, // antimatter + alloys + radioactives + power β Plasma Torpedoes | |
| requires: ["antimatterforge", "aicores"], | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| plasmarig: { | |
| id: "plasmarig", name: "Plasma Rig", hp: 560, radius: 18, | |
| // Expensive and high-tech: MANUFACTURED materials to build (machinery + electronics) and an | |
| // installed AI to pilot it (ai cores), on top of a big ore frame. | |
| cost: { ore: 500, machinery: 8, electronics: 5, ai: 3 }, | |
| buildTime: 46, sight: 130, | |
| requires: ["aifoundry", "reactor"], // needs the AI (pilot) and a Reactor (the plasma/power grid) | |
| odysseyOnly: true, | |
| // Deep-core extraction: an UNLIMITED source of one raw commodity (its "vein", fixed by where | |
| // you place it), dug in cycles at a PROBABILISTIC yield tier (low β overwhelming) whose odds | |
| // rise with the spot's + planet's richness. Draws heavy Power (the plasma arc β taxes the grid | |
| // like a factory, so digs slow when power is short) and burns radioactives per dig ("nuclear to | |
| // exploit"). See engine/rig.js. | |
| rig: { power: 16, nuclear: 1.4, digTime: 6, base: 6 }, | |
| // Its dug ore doesn't teleport into your treasury: it piles up in a FINITE output buffer | |
| // (storeCap) that workers must haul to a Command Center. Full buffer β the rig stalls until | |
| // it's cleared β logistics, not free money. See engine/haul.js. | |
| storeCap: 120, | |
| category: "industrial", | |
| }, | |
| stardock: { | |
| id: "stardock", name: "Star Dock", hp: 600, radius: 22, | |
| cost: { ore: 350 }, buildTime: 40, sight: 160, | |
| produces: ["leviathan", "heliumbomb"], // the strategic-good capital ship, and the doomsday device beside it | |
| requires: ["aifoundry", "torpedoworks"], // building it proves you've teched the whole Strategic tree | |
| odysseyOnly: true, | |
| category: "military", | |
| }, | |
| torpedobattery: { | |
| id: "torpedobattery", name: "Plasma Torpedo Battery", hp: 420, radius: 14, | |
| cost: { ore: 250, alloys: 10 }, buildTime: 24, sight: 210, | |
| // The static-defense ladder's Strategic-tier rung: the Sentinel Turret is wallpaper by | |
| // this point, exactly when the player needs to hold ground for the Antimatter Gate's 150s | |
| // charge. Real ammo-fed logistics, not a free-fire structure: `ammo` joins recipe/combust as | |
| // a real haulable input commodity (realInputComs/inputCapOf below), so engine/haul.js's | |
| // existing SERVICE machinery delivers plasma torpedoes into building.input the same way a | |
| // Reactor's fuel larder gets fed β engine/combat.js's updateBuildingCombat then requires | |
| // input[ammo.com] >= ammo.perShot before firing, decrementing on every shot; a dry battery | |
| // holds fire rather than shooting for free. | |
| attack: 55, range: 180, cooldown: 2.5, aggroRange: 180, | |
| ammo: { com: "plasmatorp", perShot: 0.25 }, | |
| // Deliberately out-ranges the Breacher (150) β by the Strategic tier the early siege tool | |
| // stops being enough on its own β but the Colossus (185) and Leviathan (200) both still | |
| // out-range it, so sieging a battery line stays the intended top-tier answer. | |
| requires: ["torpedoworks"], | |
| odysseyOnly: true, | |
| category: "military", | |
| }, | |
| antimatter_gate: { | |
| id: "antimatter_gate", name: "Antimatter Gate", hp: 1200, radius: 28, | |
| cost: { ore: 800 }, buildTime: 60, sight: 200, | |
| requires: ["antimatterforge", "aifoundry", "torpedoworks"], // the true capstone: the whole Strategic tier | |
| odysseyOnly: true, | |
| // The wonder: charges by consuming `feed` goods over `chargeTime` seconds of | |
| // full-power charging (engine/wonder.js), clamped to the scarcest one β so the | |
| // win demands the WHOLE Strategic tier flowing, not just antimatter. At full | |
| // charge the player wins the galaxy. Razed mid-charge, the whole investment is | |
| // lost with it. The Leviathan (below) draws on the same strategic goods β feed | |
| // the Gate or build the fleet. | |
| wonder: true, feed: { ai: 0.2, antimatter: 0.3, plasmatorp: 0.1 }, chargeTime: 150, | |
| powerDraw: 8, // a charging Gate loads the grid β it competes with your factories for Reactor Power | |
| category: "infrastructure", | |
| }, | |
| /* ---- TRADE INDUSTRY (Odyssey only): a genuinely SEPARATE branch off the Datacenter, not a | |
| continuation of the ore -> metals -> alloys -> machinery -> antimatter spine above (docs/ | |
| improvement-proposals.md "Promote the legacy consumer-goods recipes into a trade-industry | |
| branch"). data.js RECIPES documents 'chem'/'consumer' as legacy β "no producer" β reserved | |
| for exactly this promotion; the recipes themselves are untouched, only wired here (see | |
| engine/techtree.js TECHS.chemistry/consumerfab for the tech-gate half). | |
| The Chemical Plant needs NO metallurgy/Smelter prereq at all β `chemistry` is a second ROOT | |
| tech, the same shape as `metallurgy` itself β so a biomass/spice world that never touches | |
| ore/crystals still gets a real industrial identity of its own. The Fabricator is where the | |
| two spines actually MEET: its recipe (alloys + chemicals -> goods) needs an Assembly Plant's | |
| alloys the same way the Machine Works needs one for its own alloys+electronics merge, so it | |
| converges on the metallurgy branch without ever touching the antimatter/military one β no | |
| Foundry, Arsenal, Barracks, machining, or antimatter token anywhere in this branch. A | |
| genuinely different way up the tree: a credits engine for fuel/tribute/freight (finished | |
| goods sell dear on a low-industry world, engine/aiArchetypes.js "Verdani the low-industry | |
| agri contrast") rather than strategic goods. Ore-costed like every other factory (the | |
| reachability convention β ore is guaranteed near every base, regardless of what the recipe | |
| itself consumes), odysseyOnly, and dropOff/recipe/prodRate follow the Smelter/Assembly Plant | |
| idiom exactly. ---- */ | |
| chemplant: { | |
| id: "chemplant", name: "Chemical Plant", hp: 420, radius: 16, | |
| cost: { ore: 165 }, buildTime: 18, sight: 140, | |
| dropOff: true, | |
| recipe: "chem", prodRate: 2, // biomass + power β chemicals (data.js RECIPES.chem) | |
| requires: ["chemistry"], // tech-gated only β no Smelter, no metallurgy: a true off-spine root | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| fabricator: { | |
| id: "fabricator", name: "Fabricator", hp: 450, radius: 17, | |
| cost: { ore: 200 }, buildTime: 21, sight: 140, | |
| dropOff: true, | |
| recipe: "consumer", prodRate: 1, // alloys + chemicals + power β goods (data.js RECIPES.consumer) | |
| // The merge point: needs the metallurgy branch's Assembly Plant (alloys) AND this branch's own | |
| // Chemical Plant (chemicals), plus its own unlock tech β the same two-supplying-buildings- | |
| // plus-tech shape Machine Works uses for its own alloys+electronics merge. | |
| requires: ["assembler", "chemplant", "consumerfab"], | |
| odysseyOnly: true, | |
| category: "industrial", | |
| }, | |
| }; | |
| // Prerequisites are satisfied for `owner` when: every building-type token in | |
| // `def.requires` has a COMPLETED (non-constructing) building of that type, and | |
| // every other token is a researched upgrade id. No `requires` field β always | |
| // available. Pure and state-reading β the single source of truth shared by the | |
| // production gate, the build gate, the AI, and the UI's locked buttons. | |
| export function prereqsMet(state, owner, def) { | |
| const reqs = def.requires; | |
| if (!reqs || reqs.length === 0) return true; | |
| const player = state.players[owner]; | |
| return reqs.every(req => { | |
| if (BUILDINGS[req]) { | |
| for (const b of state.buildings.values()) | |
| if (b.owner === owner && b.type === req && !b.constructing) return true; | |
| return false; | |
| } | |
| return !!(player && player.upgrades && player.upgrades[req]); | |
| }); | |
| } | |
| // A building is a resource drop-off if it's the Command Center or a building flagged | |
| // `dropOff` in its def (the Odyssey-only factory chain β smelter, assembler, chipfab, | |
| // etc. β flags this; no skirmish building besides the Command Center does). Workers | |
| // deposit their haul at the NEAREST drop-off (see gather.js). Single source of truth | |
| // for the routing, tests, and any future UI. | |
| export function isDropOff(type) { | |
| const def = BUILDINGS[type]; | |
| return !!(def && (def.isCommandCenter || def.dropOff)); | |
| } | |
| // Where a gatherer may DEPOSIT a raw haul: the Command Center, or a `dropOff` building | |
| // with no `recipe` β NOT a factory, which drops its raws-as-inputs elsewhere and whose | |
| // `store` is its refined OUTPUT buffer, not a raw-intake bin. In practice every `dropOff` | |
| // building in the roster carries a `recipe` (the Odyssey factory chain), so this resolves | |
| // to the Command Center alone β there is no forward/decentralized collection point. | |
| export function isGatherDropOff(type) { | |
| const def = BUILDINGS[type]; | |
| return !!(def && (def.isCommandCenter || (def.dropOff && !def.recipe))); | |
| } | |
| // ---- Worker capability: which unit types may found/assist-build a building category, gather a | |
| // raw node, or run a haul/service/ferry job β end to end through recycling (recycling itself | |
| // stays universal, see recycle.js: it's the target tearing itself down, not a worker action, so | |
| // it needs no gate here). Mirrors isDropOff/isGatherDropOff's shape: a small pure predicate over a | |
| // def flag. `UNITS[type].buildCategories` lists which of BUILDINGS[type].category values that | |
| // unit may found/assist-build; `canGather`/`canLogistics` gate gathering and haul/service/ferry | |
| // the same way. Worker is the one generalist that carries all three (every category, gather, | |
| // logistics). Repair (Mender, role:"support") is intentionally untouched by all of this β it's | |
| // its own separate specialist, ungated by any of these three. | |
| export function canBuildCategory(unitType, category) { | |
| return !!category && !!UNITS[unitType]?.buildCategories?.includes(category); | |
| } | |
| /** Whether `unitType` may found/assist-build `buildingType` (its BUILDINGS[type].category). */ | |
| export function canBuildType(unitType, buildingType) { | |
| return canBuildCategory(unitType, BUILDINGS[buildingType]?.category); | |
| } | |
| /** Whether `unitType` may be issued a gather order (mine a raw resource node). */ | |
| export function canGatherType(unitType) { | |
| return !!UNITS[unitType]?.canGather; | |
| } | |
| /** Whether `unitType` may be offered/issued a haul, service, or ferry job (engine/haul.js). */ | |
| export function canLogisticsType(unitType) { | |
| return !!UNITS[unitType]?.canLogistics; | |
| } | |
| // Whether a building type can be ELECTRIFIED (Odyssey): wired into the power grid to run 30% | |
| // better β a producer (Command Center / Barracks / Star Dock) trains faster, a Habitat houses | |
| // more (engine/industry.js electrifyBoost; production.js, supply.js apply it). Eligible = a | |
| // building that doesn't ALREADY live on the power economy (no recipe to run, no Power to grant, | |
| // no rig/wonder draw) and has something worth boosting (`produces` a unit, or `supplyGrants`). | |
| // So the factories (already power-hungry), Reactors/Generators (they GRANT power), turrets and | |
| // other non-producers (Refinery, Foundry, Arsenal, Datacenter) are all excluded. Pure def | |
| // predicate β deterministic, shared by the draw math, the boost application, and the HUD toggle. | |
| export function isElectrifiable(type) { | |
| const def = BUILDINGS[type]; | |
| if (!def) return false; | |
| if (def.recipe || def.energyGrants || def.rig || def.wonder || def.combust) return false; | |
| return !!(def.produces || def.supplyGrants); | |
| } | |
| // ---- Finite storage (Odyssey logistics) --------------------------------------- | |
| // A producing building banks its OUTPUT into a finite `building.store` buffer, and a | |
| // factory OR a fuel-burning power station draws its INPUTS (a recipe's ingredients, or a | |
| // Combustion Generator's / Biomass Reactor's fuel) from a finite `building.input` buffer β | |
| // both commodityβqty maps, capped by the def. Goods aren't spendable (or consumable) until a | |
| // worker moves them (engine/haul.js): output is hauled to a Command Center, inputs are | |
| // carried in from the treasury. So storage is a real, mindful constraint, not an infinite sink. | |
| // | |
| // Caps: a def can pin `storeCap` / `inputCap` explicitly (the Plasma Rig pins a big storeCap | |
| // and has no input buffer β it digs its own raw ore, nothing to feed it); any building with a | |
| // `recipe` (the factories) OR a `combust` (the fuel-burning power stations) otherwise gets the | |
| // shared default. Everything else has no buffer (cap 0), so these helpers are no-ops for the | |
| // whole skirmish roster. | |
| const DEFAULT_FACTORY_STORE = 80; // a factory's output backlog before it stalls, ~tens of batches | |
| const DEFAULT_FACTORY_INPUT = 80; // a factory's WHOLE input larder before it starves β split evenly | |
| // per real input commodity below, NOT one pool every commodity races for | |
| const RECIPE_BY_ID = Object.fromEntries(RECIPES.map(r => [r.id, r])); | |
| /** The output-buffer capacity of a building type, or 0 if it has no buffer. */ | |
| export function storeCapOf(type) { | |
| const def = BUILDINGS[type]; | |
| if (!def) return 0; | |
| if (def.storeCap != null) return def.storeCap; | |
| return def.recipe ? DEFAULT_FACTORY_STORE : 0; | |
| } | |
| // The REAL (haulable) input commodities a building needs hauled in β a recipe's ingredients | |
| // (every key but "energy", which is a live per-tick Power draw, engine/industry.js, never a | |
| // hauled/stored good), a fuel-burning power station's acceptable fuels (def.combust.fuels β | |
| // gas for the Combustion Generator, biomass for the Biomass Reactor; a building CAN accept more | |
| // than one β ANY ONE of them keeps it running, not all at once β see haul.js inputNeedsOf/ | |
| // neededInput, which already treats "pick whichever's neediest" the same way for both an | |
| // AND-recipe and an OR-fuel-choice), or an ammo-fed static defense's single feed commodity | |
| // (def.ammo.com β the Torpedo Battery's plasmatorp). Empty for a building with none of the | |
| // three (or an unknown recipe id β belt-and-suspenders for stale data). | |
| function realInputComs(type) { | |
| const def = BUILDINGS[type]; | |
| const recipe = RECIPE_BY_ID[def?.recipe]; | |
| if (recipe) return Object.keys(recipe.in).filter(c => c !== "energy"); | |
| if (def?.combust) return def.combust.fuels; | |
| if (def?.ammo) return [def.ammo.com]; | |
| return []; | |
| } | |
| // The input larder's total capacity is split EVENLY across however many real commodities the | |
| // recipe needs, so each gets its OWN guaranteed slice β a 2-input recipe (e.g. chipfab: crystals + | |
| // metals) gets 40 each of an 80 total, a 3-input one (e.g. plasmafab) gets ~26.7 each. Without | |
| // this a single over-supplied input (workers keep bringing whichever the treasury happens to have | |
| // plenty of) can fill the WHOLE shared pool on its own, leaving zero room for whichever OTHER | |
| // input the recipe is actually starved of β the factory then sits stalled forever on a "Starved" | |
| // input it can never actually receive, since haul.js's inputRoom check (below) always reads 0. | |
| /** The per-commodity input-buffer capacity of a building type (a factory, a fuel-burning power | |
| * station, or an ammo-fed static defense), or 0 if it has none. */ | |
| export function inputCapOf(type) { | |
| const def = BUILDINGS[type]; | |
| if (!def) return 0; | |
| const total = def.inputCap != null ? def.inputCap : ((def.recipe || def.combust || def.ammo) ? DEFAULT_FACTORY_INPUT : 0); | |
| if (total <= 0) return 0; | |
| const n = realInputComs(type).length; | |
| return n > 0 ? total / n : total; | |
| } | |
| /** Sum a commodityβqty buffer (order-independent β deterministic); 0 for a missing buffer. */ | |
| function bufTotal(buf) { | |
| let t = 0; | |
| if (buf) for (const c in buf) t += buf[c] || 0; | |
| return t; | |
| } | |
| /** How much is currently sitting in a building's output buffer (0 if none). */ | |
| export function storeTotal(building) { return bufTotal(building.store); } | |
| /** The free room left in a building's output buffer (clamped β₯ 0). */ | |
| export function storeRoom(building) { | |
| return Math.max(0, storeCapOf(building.type) - storeTotal(building)); | |
| } | |
| /** How much (of every commodity combined) is currently sitting in a factory's input buffer. */ | |
| export function inputTotal(building) { return bufTotal(building.input); } | |
| /** The free room left for ONE SPECIFIC input commodity (clamped β₯ 0) β each real input commodity | |
| * gets its own independent slice of the larder (inputCapOf), so one oversupplied input can never | |
| * crowd out room for another the recipe still needs. @param {Building} building @param {string} com */ | |
| export function inputRoom(building, com) { | |
| return Math.max(0, inputCapOf(building.type) - (building.input?.[com] || 0)); | |
| } | |
| // ---- Freighter cargo hold (Odyssey freight) ------------------------------------ | |
| // A freighter's `freight` is a multi-commodity hold (unlike a worker's single-slot `cargo`), | |
| // capped by `def.cargoHold`. Lives here (not engine/galaxy.js, where the player-manual | |
| // load/unload panel logic lives) so engine/haul.js can read a freighter's room/fill too β the | |
| // workerβfreighter physical loading path β without importing galaxy.js, which itself imports | |
| // engine/sim.js -> engine/haul.js and would otherwise be a cycle. galaxy.js re-exports both. | |
| /** How much is currently aboard a freighter's hold (0 for a non-freighter unit). */ | |
| export function freightUsed(unit) { return unit ? bufTotal(unit.freight) : 0; } | |
| /** A freighter's remaining hold room = its cargoHold minus what's aboard (0 for a non-freighter). */ | |
| export function freightRoom(unit) { | |
| const cap = unit ? (UNITS[unit.type]?.cargoHold || 0) : 0; | |
| return Math.max(0, cap - freightUsed(unit)); | |
| } | |
| // Player-wide Refinery upgrades, arranged as three MUTUALLY EXCLUSIVE doctrines, each three tiers | |
| // deep (docs/improvement-proposals.md's doctrine-depth redesign brought Assault and Bulwark up to | |
| // the depth Logistics already had). You commit to Assault (offense), Bulwark (defense), or | |
| // Logistics (economy/tempo) β researching any upgrade of one doctrine locks the others β and can | |
| // then deepen your chosen path tier by tier (each requiring the tier right below it, via the same | |
| // prereqsMet machinery the tech tree uses). So "which upgrade" is a real strategic fork with an | |
| // opportunity cost, not a buy-both no-brainer. Like the Odyssey tech tree's TECHS | |
| // (engine/techtree.js), each entry is PAID ON ENQUEUE and DEVELOPED OVER TIME β `time` is seconds | |
| // to develop at a tech-5 world, scaled by researchTimeScale β queued at a Refinery via | |
| // production.js's researchUpgrade and ticked by the SAME updateResearch loop the Datacenter uses | |
| // (it resolves TECHS or UPGRADES by building.type). Effects apply live, army-wide (and base-wide | |
| // β see reinforcedPlating/reinforcedBulwark below), the instant a queued upgrade completes; | |
| // multipliers stack multiplicatively in combat.js. Assault costs radioactives, Bulwark crystals β | |
| // so a world's deposit specialty tilts which combat doctrine comes easier. | |
| // Each doctrine's Tier-3 is a CAPSTONE, additionally gated on a completed Arsenal (not just its | |
| // own Tier-2) β Logistics' is a capability flag (recycling); Assault's and Bulwark's each change | |
| // TEXTURE, not just magnitude, so the deepening tier adds a genuine tactical verb rather than a | |
| // third copy of the same multiplier (see overdriveActuators/selfSealingPlating below). | |
| // `ico` is the doctrine's emblem (assault βοΈ, bulwark π‘οΈ, logistics π¦), reused on the Refinery | |
| // research buttons so the doctrine reads at a glance β same iconography as the rest of the HUD; | |
| // a Tier-3 capstone breaks from its own doctrine's emblem for a more specific one instead | |
| // (recycling β»οΈ, overdriveActuators β‘, selfSealingPlating π©Ή), the same way recycling already did. | |
| export const UPGRADES = { | |
| overchargedWeapons: { | |
| id: "overchargedWeapons", name: "Overcharged Weapons", doctrine: "assault", tier: 1, ico: "βοΈ", | |
| cost: { radioactives: 150 }, time: 25, desc: "+15% damage dealt by all combat units", damageDealtMult: 1.15, | |
| }, | |
| // Doctrine depth (docs/improvement-proposals.md "Give the doctrine Tier-2s a verb"): a flat | |
| // damage stack alone changes no decision a player makes IN a fight, so Tier-2 also grants a | |
| // tactical verb on top of it β chaseSpeedMult moves an Assault army ~10% faster specifically | |
| // while it's chasing an already-acquired target (the out-of-weapon-range chase branch in | |
| // engine/combat.js updateCombat), never on a plain move order. Read by upgradeMult exactly like | |
| // damageDealtMult β it's a genuine multiplier, just consumed at a different call site. | |
| overchargedCore: { | |
| id: "overchargedCore", name: "Overcharged Core", doctrine: "assault", tier: 2, ico: "βοΈ", | |
| cost: { radioactives: 200, ore: 120 }, time: 32, requires: ["overchargedWeapons"], | |
| desc: "+15% more damage dealt (stacks with Overcharged Weapons); +10% move speed while chasing an acquired target", | |
| damageDealtMult: 1.15, chaseSpeedMult: 1.1, | |
| }, | |
| // The Arsenal-gated capstone (docs/improvement-proposals.md "Tier-3 doctrine capstones"): | |
| // texture, not just magnitude. Tier-2's chaseSpeedMult is about CLOSING a fight; this is about | |
| // WINNING it once joined β a genuinely different tactical identity (tempo), so it needs no | |
| // reconciliation with Tier-2 the way Bulwark's regen does below. Applied at every "reset the | |
| // cooldown after a landed shot" site in engine/combat.js β the main unit attack path and | |
| // updateWorkerCombat β but deliberately NOT updateBuildingCombat: this is the ARMY's tempo | |
| // upgrade (mobile units only), not a base-defense buff. Bulwark, not Assault, is the doctrine | |
| // that already reaches structures too (see reinforcedPlating's own comment below). Mixed | |
| // building+upgrade requires β prereqsMet already resolves both token kinds. | |
| overdriveActuators: { | |
| id: "overdriveActuators", name: "Overdrive Actuators", doctrine: "assault", tier: 3, ico: "β‘", | |
| cost: { radioactives: 220, ore: 140 }, time: 38, requires: ["overchargedCore", "arsenal"], | |
| desc: "-10% attack cooldown β the army shoots faster once it's engaged", attackCooldownMult: 0.9, | |
| }, | |
| // "all units and structures", not just combat units: attackDamage (engine/combat.js) applies | |
| // damageTakenMult to EVERY target it computes damage for β units AND buildings alike, with no | |
| // kind/role filter β so Bulwark already shields turrets, the Command Center, and Habitats too. | |
| // Bulwark is the defense doctrine for the army AND the base. | |
| reinforcedPlating: { | |
| id: "reinforcedPlating", name: "Reinforced Plating", doctrine: "bulwark", tier: 1, ico: "π‘οΈ", | |
| cost: { crystals: 150 }, time: 25, desc: "-12% damage taken by all units and structures", damageTakenMult: 0.88, | |
| }, | |
| // Doctrine depth, the Bulwark half (docs/improvement-proposals.md "Give the doctrine Tier-2s a | |
| // verb" AND "Tier-3 doctrine capstones" β RECONCILED into one mechanic here; see | |
| // selfSealingPlating below for why there's only one regen system, not two independently-tracked | |
| // ones). This is the verb itself: role:"combat" army units passively heal `regenRate` hp/s once | |
| // `regenDelay` seconds have passed since they last took a hit (engine/repair.js | |
| // updateBulwarkRegen reads these two fields directly, like recycling's capability flag below β | |
| // NOT through the generic upgradeMult product, since a rate and a delay aren't multipliers to | |
| // stack, they're the two knobs of one mechanic). "Win attrition wars by cycling out of fights" β | |
| // strictly out-of-combat: a unit under continuous fire keeps restamping unit.lastHitAt | |
| // (engine/combat.js) every hit, so it never crosses even this delay β see | |
| // test/balance.test.js's auto-battle duels, untouched by design. | |
| reinforcedBulwark: { | |
| id: "reinforcedBulwark", name: "Reinforced Bulwark", doctrine: "bulwark", tier: 2, ico: "π‘οΈ", | |
| cost: { crystals: 200, ore: 120 }, time: 32, requires: ["reinforcedPlating"], | |
| desc: "-12% more damage taken (stacks with Reinforced Plating); army units regenerate hp once out of combat", | |
| damageTakenMult: 0.88, regenRate: 0.5, regenDelay: 8, | |
| }, | |
| // The Bulwark capstone (docs/improvement-proposals.md "Tier-3 doctrine capstones") β and the | |
| // RECONCILIATION point with "Give the doctrine Tier-2s a verb" above, which independently | |
| // proposed the exact same "out-of-combat hp regen" identity for reinforcedBulwark. Rather than | |
| // ship two separately-tracked regen fields (a silent double-heal for a player who owns both, or | |
| // a Tier-3 pick that just restates what Tier-2 already grants), this is ONE mechanic: | |
| // engine/repair.js's updateBulwarkRegen reads regenRate/regenDelay off whichever of these two | |
| // upgrades the player holds, this capstone's numbers winning whenever both are present β so a | |
| // save can never double-apply both tiers at once. And this entry genuinely DEEPENS Tier-2's | |
| // grant rather than repeating it: 3x the heal rate, well under half the unhit delay. A | |
| // Tier-2-only army must fully disengage for 8s before it heals at all; a Tier-2+3 army peels a | |
| // wounded unit out for a much shorter breather AND patches it up faster once it does β "armies | |
| // that hold ground recover" (the proposal's own framing), a sustain identity that complements | |
| // the Mender rather than replacing it. Still strictly out-of-combat at every tier: the | |
| // shortened delay is still a real delay, so a continuously-hit unit (lastHitAt restamped every | |
| // hit) never benefits at ANY tier β see test/repair.test.js's reconciliation-proving cases. | |
| selfSealingPlating: { | |
| id: "selfSealingPlating", name: "Self-Sealing Plating", doctrine: "bulwark", tier: 3, ico: "π©Ή", | |
| cost: { crystals: 220, ore: 140 }, time: 38, requires: ["reinforcedBulwark", "arsenal"], | |
| desc: "out-of-combat hp regen kicks in sooner and heals faster than Reinforced Bulwark alone", | |
| regenRate: 1.5, regenDelay: 3, | |
| }, | |
| // A third doctrine that isn't about the fight at all: Logistics trades combat | |
| // upgrades for economy and tempo. Committing to it locks Assault AND Bulwark | |
| // (and vice-versa) through the same committedDoctrine machinery, so "out-macro | |
| // them" is a real, mutually-exclusive plan against "out-fight them" β not a | |
| // free third buy. gatherYieldMult/produceTimeMult are read by gather.js and | |
| // production.js the same data-driven way the damage mults are read by combat.js. | |
| logisticsNetwork: { | |
| id: "logisticsNetwork", name: "Logistics Network", doctrine: "logistics", tier: 1, ico: "π¦", | |
| cost: { crystals: 140 }, time: 25, desc: "+25% resource yield from every haul", gatherYieldMult: 1.25, | |
| }, | |
| rapidFabrication: { | |
| id: "rapidFabrication", name: "Rapid Fabrication", doctrine: "logistics", tier: 2, ico: "π¦", | |
| cost: { crystals: 160, ore: 120 }, time: 32, requires: ["logisticsNetwork"], | |
| desc: "-20% unit & building production time", produceTimeMult: 0.8, | |
| }, | |
| // No multiplier field: unlike its two Logistics siblings above, this GATES a capability | |
| // (raises the recycling refund cap) rather than feeding the generic upgradeMult product β read | |
| // directly by id, the same way FREIGHTER_AI_TECH is (engine/recycle.js RECYCLE_TECH). Shares | |
| // that exact id with the Odyssey tech tree's own "recycling" node (engine/techtree.js) β only | |
| // one of the two trees is ever active in a given match, so the shared key is safe and lets | |
| // engine/recycle.js check a single flag regardless of mode. | |
| recycling: { | |
| id: "recycling", name: "Field Recycling", doctrine: "logistics", tier: 3, ico: "β»οΈ", | |
| cost: { crystals: 180, ore: 140 }, time: 38, requires: ["rapidFabrication"], | |
| desc: "+30% of a recycled unit/building's cost reclaimed (up to an 80% cap)", | |
| }, | |
| // Hard difficulty's economic edge (engine/aiDifficulty.js) β seeded straight onto | |
| // state.players.ai.upgrades at creation (engine/state.js), never researched, so it needs | |
| // none of a real entry's machinery: no cost, no time, no doctrine, no tier/requires. It rides | |
| // the exact same gatherYieldMult/produceTimeMult fields logisticsNetwork/rapidFabrication use β | |
| // gather.js/production.js need no changes at all to pick it up. `aiOnly` keeps it out of the | |
| // player-facing Refinery research panel and its cost fingerprint (hudSelection.js); having no | |
| // `doctrine` keeps it invisible to committedDoctrine (which now guards on `.doctrine` | |
| // explicitly β see below) and to aiEconomy.js's per-doctrine research path. | |
| hardEdge: { | |
| id: "hardEdge", name: "Hard Edge", aiOnly: true, | |
| gatherYieldMult: 1.1, produceTimeMult: 0.9, | |
| }, | |
| }; | |
| // Product of a multiplier field across a player's RESEARCHED upgrades (1 when | |
| // none apply) β the single place combat/gather/production read upgrade effects. | |
| // A new upgrade is then pure data: give it a field, and its multiplier is picked | |
| // up wherever that field is consumed (damageDealtMult, damageTakenMult, | |
| // gatherYieldMult, produceTimeMult, ...). | |
| export function upgradeMult(upgrades, field) { | |
| let m = 1; | |
| if (!upgrades) return m; | |
| for (const id of Object.keys(upgrades)) { | |
| if (upgrades[id] && UPGRADES[id] && UPGRADES[id][field]) m *= UPGRADES[id][field]; | |
| } | |
| return m; | |
| } | |
| // The doctrine a player has committed to β the doctrine of any upgrade they've | |
| // researched, OR is still developing (queued at a Refinery, not yet complete) β or | |
| // null if neither. Researching (or queuing) an upgrade of the other doctrine is | |
| // then locked out (see production.js's researchUpgrade). The queued half matters | |
| // because doctrine research now DEVELOPS OVER TIME instead of landing instantly | |
| // (engine/techtree.js updateResearch): the cost is paid up front on enqueue, so the | |
| // commitment has to bite the instant it's queued, not only once the timer finishes | |
| // β otherwise both doctrines could sit queued side-by-side during the development | |
| // window, defeating the whole "real commitment" point above. Scoped to `type === | |
| // "refinery"` specifically (not just "any researchQueue"): a Datacenter's OWN queue | |
| // uses the SAME field name but resolves against TECHS, which shares no doctrine | |
| // tokens with UPGRADES except the deliberately-overlapping "recycling" id β cross- | |
| // checking a Datacenter job against UPGRADES here would misread that shared id as a | |
| // Logistics commitment. `state.buildings` is optional-chained so a bare test state | |
| // (no buildings map at all) reads as "nothing queued", identical to before this queued | |
| // half existed. | |
| // Guards on `.doctrine` itself, not just UPGRADES membership: a non-doctrine entry | |
| // (hardEdge, Hard difficulty's seeded economic edge) must stay invisible here, the same | |
| // way researchUpgrade's own doctrine-lock check already reads `def.doctrine` rather than | |
| // just `UPGRADES[id]` β without this, hardEdge being seeded before any real research would | |
| // make this return `undefined` forever, silently disabling the doctrine lock entirely. | |
| export function committedDoctrine(state, owner) { | |
| const ups = state.players[owner].upgrades; | |
| for (const id of Object.keys(ups)) if (ups[id] && UPGRADES[id]?.doctrine) return UPGRADES[id].doctrine; | |
| for (const b of state.buildings?.values() || []) { | |
| if (b.owner !== owner || b.type !== "refinery" || !b.researchQueue) continue; | |
| for (const job of b.researchQueue) { const def = UPGRADES[job.techId]; if (def?.doctrine) return def.doctrine; } | |
| } | |
| return null; | |
| } | |
| // A STANDING structure's bonus: the product of a multiplier field across a player's own | |
| // COMPLETED buildings whose def carries it (e.g. produceTimeMult on the Foundry/Arsenal above) β | |
| // the live-scanned counterpart to upgradeMult/techMult, which read a RESEARCHED id instead of a | |
| // building that has to keep standing. Mirrors committedDoctrine's own live scan just above: razing | |
| // the building drops the bonus on the very next read, with no separate flag to track or clear. | |
| // Multiple standing structures with the same field STACK multiplicatively (a Foundry AND an | |
| // Arsenal both apply), same rule upgradeMult/techMult already use for stacking research. 1 (no-op) | |
| // for an owner with no completed building carrying that field. | |
| export function structureMult(state, owner, field) { | |
| let m = 1; | |
| for (const b of state.buildings?.values() || []) { | |
| if (b.owner !== owner || b.constructing) continue; | |
| const def = BUILDINGS[b.type]; | |
| if (def && def[field]) m *= def[field]; | |
| } | |
| return m; | |
| } | |
| // Veterancy ranks (docs/improvement-proposals.md "Veterancy ranks: kills forge small combat | |
| // multipliers and visible chevrons"): each rank layers on the same ~+6% damage-dealt / -6% | |
| // damage-taken edge, so a whole campaign's survivors compound rather than jumping to full | |
| // strength off one lucky trade. Thresholds (3/8/18 kills) are spaced so a single engagement | |
| // rarely mints even rank 1 β combat.js's performAttack only increments `kills` on a CONFIRMED | |
| // kill by a unit-kind attacker, so racking up 3 in one fight means winning three separate | |
| // exchanges, not one. Verified against test/balance.test.js's whole auto-battle regression | |
| // harness (the duel/cracksBase-style invariants), which stays green with veterancy folded in. | |
| export const VETERANCY_RANKS = [ | |
| { kills: 3, dealtMult: 1.06, takenMult: 0.94 }, | |
| { kills: 8, dealtMult: 1.06 ** 2, takenMult: 0.94 ** 2 }, | |
| { kills: 18, dealtMult: 1.06 ** 3, takenMult: 0.94 ** 3 }, | |
| ]; | |
| // A unit's veterancy rank (0..VETERANCY_RANKS.length) and the two multipliers that go with it β | |
| // 1/1 (no-op) below the first threshold, or for anything with no `kills` field at all (a fresh | |
| // unit off the line, or a building/turret, which never accrues kills). Pure and side-effect-free | |
| // so attackDamage (combat.js) and the chevron renderer (renderUnits.js) can both call it straight | |
| // off the entity with no shared bookkeeping. | |
| /** @param {Unit} unit @returns {{ rank: number, dealtMult: number, takenMult: number }} */ | |
| export function rankMults(unit) { | |
| const kills = unit?.kills || 0; | |
| let rank = 0; | |
| for (const r of VETERANCY_RANKS) { if (kills < r.kills) break; rank++; } | |
| const tier = VETERANCY_RANKS[rank - 1]; | |
| return { rank, dealtMult: tier ? tier.dealtMult : 1, takenMult: tier ? tier.takenMult : 1 }; | |
| } | |
| // Skiff, Bastion and Lancer form a deliberate rock-paper-scissors: each | |
| // one's bonusVs targets exactly the unit that would otherwise be its | |
| // hardest matchup, and nothing beats all three at once. | |
| // Skiff -> beats Lancer (fast raiders shred a lightly-armored gun platform before it can find its range) | |
| // Bastion -> beats Skiff (heavy armor shrugs off skiff fire and tears through light hulls up close) | |
| // Lancer -> beats Bastion (armor-piercing rounds built specifically to punch through heavy plating) | |
| export const UNITS = { | |
| worker: { | |
| id: "worker", name: "Worker", hp: 40, radius: 6, speed: 60, | |
| cost: { ore: 50 }, altCost: { biomass: 50 }, buildTime: 8, supplyCost: 1, | |
| // `altCost` is an EITHER/OR price: a worker can be trained on ore (the default) or on | |
| // biomass instead (engine/production.js queueProduction's `alt` flag) β so a crystal/ore-poor, | |
| // biomass-rich claim can still grow its workforce. The queued job remembers which it paid, so a | |
| // cancel refunds the right commodity. | |
| role: "worker", gatherRate: 10, cargoCap: 10, | |
| // Saturation: the first `minerSoftCap` workers on a deposit mine at full | |
| // rate; each one past that pulls only `minerFalloff` of a full share (see | |
| // gather.js's miningEfficiency), so over-piling a node has diminishing | |
| // returns and spreading out / expanding to a fresh field is a real call. | |
| // The cap matches the ~3 home seams and the 3 starting workers, so the | |
| // opening economy is never penalised. | |
| minerSoftCap: 3, minerFalloff: 0.4, | |
| sight: 110, | |
| // Can defend itself in a pinch β a weak short-range strike β but only when | |
| // explicitly ordered to attack. Workers never auto-acquire, so they don't | |
| // abandon the economy to go pick fights; a handful ganging up can drive off | |
| // a lone raider, but they're no substitute for real military units. | |
| attack: 4, range: 15, cooldown: 1.4, | |
| // The one economy unit, and it does everything: every build category, gather, AND | |
| // logistics β no specialization, no gate. | |
| buildCategories: ["infrastructure", "military", "industrial"], canGather: true, canLogistics: true, | |
| }, | |
| ranger: { | |
| id: "ranger", name: "Ranger", hp: 50, radius: 6, speed: 115, | |
| cost: { ore: 45 }, buildTime: 10, supplyCost: 1, | |
| // A dedicated recon unit, built at the Command Center like a Worker. Cheap | |
| // and fragile β it scouts, it doesn't fight β with a token bite for | |
| // self-defence only (role "scout" β it never auto-acquires, so it won't wander | |
| // into a fight on its own; the attack fires only when explicitly ordered, like | |
| // a Worker's). Its edges are the three things a scout wants: | |
| role: "scout", attack: 6, range: 22, cooldown: 1.3, | |
| sight: 340, // sees far β vision is the whole point of the unit | |
| allTerrain: true, // ice fields and rough ground never slow it (movement.js honours this flag) | |
| // ...and a "scout mode" (issueScout / order type "scout") where it ranges the | |
| // map on its own, heading for the nearest unexplored ground β see scout.js. | |
| }, | |
| colonyship: { | |
| id: "colonyship", name: "Colony Ship", hp: 600, radius: 13, speed: 48, | |
| cost: { ore: 500 }, buildTime: 40, supplyCost: 3, | |
| // The mobile seed of a base (Odyssey only): move it to a site and DEPLOY it | |
| // (engine/colony.js) into a Command Center; its colonists β the opening workers | |
| // β disembark on deploy. Its own non-combat role: unarmed (no `attack`, so it | |
| // can never be ordered to fight), never auto-acquires, and is transparent to the | |
| // army/threat scans β but the main-acquire still lets enemies shoot it, so it's a | |
| // real escort risk on the trek, not incidentally invincible. Pricier than the CC | |
| // (400) it replaces: the mobility premium. | |
| role: "colony", sight: 200, | |
| odysseyOnly: true, | |
| }, | |
| skiff: { | |
| id: "skiff", name: "Skiff", hp: 72, radius: 7, speed: 90, | |
| cost: { ore: 100 }, buildTime: 12, supplyCost: 1, | |
| role: "combat", attack: 12, range: 40, cooldown: 1, | |
| sight: 160, aggroRange: 120, | |
| // Fast and cheap, but a touch fragile (72 hp): it wins by harassment and | |
| // numbers, not by trading blows, so its counters punish it faster now. | |
| bonusVs: { lancer: 10 }, | |
| }, | |
| bastion: { | |
| id: "bastion", name: "Bastion", hp: 160, radius: 9, speed: 68, | |
| cost: { ore: 160 }, buildTime: 18, supplyCost: 2, | |
| role: "combat", attack: 10, range: 44, cooldown: 1.2, | |
| sight: 130, aggroRange: 110, | |
| // Its job is to catch and crush Skiffs. It now OUT-RANGES the Skiff (44 vs 40) β | |
| // just barely, still under the AI's KITE_MIN_RANGE 50 so it stands and trades rather | |
| // than kiting β so a player-microed Skiff can no longer sit at 40 and orbit forever | |
| // outside the reach of the very unit built to hard-counter it. bonusVs trimmed to 10 | |
| // (from 14) so the head-on time-to-kill stays close to before despite the extra reach. | |
| bonusVs: { skiff: 10 }, | |
| }, | |
| lancer: { | |
| id: "lancer", name: "Lancer", hp: 70, radius: 8, speed: 75, | |
| cost: { ore: 150 }, buildTime: 16, supplyCost: 2, | |
| role: "combat", attack: 16, range: 55, cooldown: 1.4, | |
| sight: 170, aggroRange: 130, | |
| bonusVs: { bastion: 20 }, | |
| requires: ["foundry"], // Tier-2: the answer to a massed Bastion ball, but you must tech to it | |
| }, | |
| breacher: { | |
| id: "breacher", name: "Breacher", hp: 130, radius: 10, speed: 50, | |
| cost: { ore: 100, radioactives: 60 }, buildTime: 20, supplyCost: 2, | |
| role: "combat", attack: 10, range: 150, cooldown: 2, | |
| sight: 180, aggroRange: 150, | |
| // Deliberately OUTSIDE the Skiff/Lancer/Bastion triangle: no bonusVs | |
| // any unit type, and no unit gets bonusVs breacher. Its whole identity | |
| // is the class-wide structure bonus below plus outranging the turret. | |
| bonusVsBuildings: 30, | |
| prefersBuildings: true, | |
| // Not a bonusVs-derived hard counter (this adds no new combat math) β a | |
| // cost-parity Skiff swarm still crushes it in practice (test/balance.test.js), | |
| // "folds to massed Skiffs for a fraction of its cost" per README. Data the | |
| // AI's counter-pick (engine/aiMilitary.js COUNTER_OF) can fold in directly. | |
| counteredBy: "skiff", | |
| requires: ["foundry"], // Tier-2 siege: a tech investment, not a turn-one option | |
| }, | |
| dreadnought: { | |
| id: "dreadnought", name: "Dreadnought", hp: 340, radius: 12, speed: 42, | |
| cost: { ore: 240, radioactives: 100 }, buildTime: 30, supplyCost: 4, | |
| role: "combat", attack: 26, range: 68, cooldown: 1.6, | |
| sight: 190, aggroRange: 150, | |
| // Tier-3 capital platform: tanky, long-reaching, heavy-hitting β a real | |
| // power spike you tech to (Barracks -> Foundry -> Arsenal). Deliberately | |
| // OUTSIDE the rock-paper-scissors triangle, like the Breacher: no bonusVs | |
| // any type and nothing bonuses against it, and its steep cost + 4 supply | |
| // mean cheaper massed units still trade cost-effectively into it β so it | |
| // anchors an army rather than hard-countering it. | |
| counteredBy: "skiff", // the cost-efficiency answer above, certified by test/balance.test.js's supply-parity duel | |
| requires: ["arsenal"], | |
| }, | |
| mender: { | |
| id: "mender", name: "Mender", hp: 70, radius: 8, speed: 78, | |
| cost: { ore: 90, crystals: 60 }, buildTime: 16, supplyCost: 2, | |
| // A pure-support drone: no attack of any kind (role "support" β it never | |
| // auto-acquires and combat.js never gives it a weapon). It closes a real | |
| // gap in the roster β until now every hp lost was permanent, so attrition | |
| // always favoured whoever could out-produce. A Mender lets a bruised army | |
| // heal between fights and lets a besieged base patch its buildings, so | |
| // *keeping* an army alive becomes a strategy, not just re-buying it. | |
| role: "support", requires: ["foundry"], sight: 130, | |
| // Heals every damaged friendly unit AND building within repairRange at | |
| // repairRate hp/sec each (see repair.js) β capped at each target's maxHp, | |
| // never itself, never something still under construction. Fragile and | |
| // unarmed, so it's a priority kill: escort it or lose it. | |
| repairRate: 6, repairRange: 110, | |
| }, | |
| // The convoy freighter β a defenceless cargo hauler used only by the scenario | |
| // modes (engine/scenarios.js), never trained at a building. Slow, no weapon | |
| // (role "freighter" β it never auto-acquires and combat.js never arms it), but | |
| // a big hull so it can survive a raid long enough for its escort to answer. | |
| // supplyCost/sight satisfy the roster invariants; cost is empty (it's spawned, | |
| // not bought). | |
| freighter: { | |
| id: "freighter", name: "Freighter", hp: 220, radius: 11, speed: 46, | |
| cost: {}, buildTime: 0, supplyCost: 1, | |
| role: "freighter", sight: 130, | |
| }, | |
| /* ---- ODYSSEY FREIGHT β cargo ships in three sizes. A jump's cargo hold is the combined | |
| `cargoHold` of the cargo ships staged for it (engine/galaxy.js freightCapacity), so hauling | |
| manufactured goods between worlds means building and staging shipping: a bigger fleet, or | |
| bigger ships, moves more per trip. Role "freighter" β they carry no weapon (combat.js never | |
| arms them), so they need escorting. Gated behind the Spaceport (no point shipping without a | |
| jump pad) and Odyssey-only, so the skirmish roster + its byte-identical replay are untouched. | |
| Their supplyCost also draws on the Spaceport's per-jump capacity, so a Bulk Freighter needs a | |
| bigger pad to lift alongside an army. ---- */ | |
| hauler: { | |
| id: "hauler", name: "Hauler", hp: 180, radius: 9, speed: 58, | |
| cost: { ore: 90 }, buildTime: 12, supplyCost: 1, | |
| role: "freighter", sight: 130, cargoHold: 250, odysseyOnly: true, requires: ["spaceport"], | |
| }, | |
| heavyhauler: { | |
| id: "heavyhauler", name: "Heavy Hauler", hp: 350, radius: 12, speed: 44, | |
| cost: { ore: 220 }, buildTime: 24, supplyCost: 3, | |
| role: "freighter", sight: 140, cargoHold: 650, odysseyOnly: true, requires: ["spaceport"], | |
| }, | |
| bulkfreighter: { | |
| id: "bulkfreighter", name: "Bulk Freighter", hp: 600, radius: 15, speed: 32, | |
| cost: { ore: 480 }, buildTime: 42, supplyCost: 6, | |
| role: "freighter", sight: 150, cargoHold: 1600, odysseyOnly: true, requires: ["spaceport"], | |
| }, | |
| /* ---- Tier-3 SPECIALTY units: one per "rare" surface commodity, so a world's | |
| deposit specialty shapes which elite you can field (radioactives already | |
| give the balanced Dreadnought above). All Arsenal-gated, all deliberately | |
| OUTSIDE the Skiff/Bastion/Lancer triangle β situational power, not a hard | |
| counter β and each pays in a commodity that was otherwise never spent (gas | |
| on Vesper/Glacius/Nimbus, ice on Glacius, relics on Korrath/Oort). ---- */ | |
| wraith: { | |
| id: "wraith", name: "Wraith", hp: 84, radius: 8, speed: 104, | |
| cost: { ore: 120, gas: 60 }, buildTime: 18, supplyCost: 2, | |
| role: "combat", attack: 22, range: 58, cooldown: 0.85, | |
| sight: 175, aggroRange: 150, | |
| // Helium-3-fuelled strike craft: the roster's fastest combatant and its | |
| // hardest hitter per shot, but paper-thin β a glass cannon that shreds a | |
| // line and then melts to any focused fire. Gas turns a dead deposit into | |
| // the fuel for raids. | |
| counteredBy: "skiff", // "melts to any focused fire" above, certified by test/balance.test.js's cost-parity duel | |
| requires: ["arsenal"], | |
| }, | |
| aegis: { | |
| id: "aegis", name: "Aegis", hp: 360, radius: 11, speed: 48, | |
| cost: { ore: 160, ice: 90 }, buildTime: 26, supplyCost: 4, | |
| role: "combat", attack: 11, range: 26, cooldown: 1.3, | |
| sight: 140, aggroRange: 110, | |
| // A cryo-armoured wall: the tankiest hull in the game with an almost token gun. It | |
| // doesn't kill things β it projects a cryo-armour bubble that REDUCES the damage its | |
| // nearby allies take (guardAura, read in combat.js attackDamage), so the army fighting | |
| // around it lives longer wherever it stands β no need to pre-position it as a literal | |
| // wall, and enemy targeting doesn't have to cooperate. The anvil to the Wraith's hammer. | |
| // Faster now (48) so it can keep formation with the line it shields. Ice is its armour. | |
| guardAura: { range: 96, damageTakenMult: 0.82 }, | |
| // Out-shot, not out-tanked: armies are supply-capped lategame, not cost-capped | |
| // (cost parity actually favours the Aegis β its hp pool buys more survivability | |
| // per ore than the Lancer's), but at equal SUPPLY the Lancer's armor-piercing | |
| // rounds out-DPS the almost-token gun before the huge hp pool matters β the | |
| // Wraith's hammer needed an anvil; the Aegis needed a sharper spear. Certified | |
| // by test/balance.test.js's supply-parity duel. | |
| counteredBy: "lancer", | |
| requires: ["arsenal"], | |
| }, | |
| colossus: { | |
| id: "colossus", name: "Colossus", hp: 150, radius: 12, speed: 32, | |
| cost: { ore: 180, relics: 80 }, buildTime: 30, supplyCost: 4, | |
| role: "combat", attack: 42, range: 185, cooldown: 2.6, | |
| sight: 210, aggroRange: 185, | |
| // A reactivated ancient siege engine: out-ranges everything on the field β | |
| // the Sentinel Turret (130) and even the Breacher (150) β and hits like a | |
| // truck, but fires slowly, crawls, and is fragile for its cost, so it must | |
| // be screened or it's sniped/rushed down. A flat structure bonus makes it a | |
| // base-cracker. Relics (ancient tech) are its ammunition. | |
| bonusVsBuildings: 25, | |
| // The T3 anti-mass verb (docs/improvement-proposals.md "Colossus splash"): the primary hit | |
| // also rattles enemy units caught within `radius` of the impact point, for `frac` of this | |
| // hit's damage, falling off linearly to zero at the edge (engine/combat.js performAttack). | |
| // This is the mechanical answer to "spread out vs artillery" β a packed formation (exactly | |
| // what separation.js's own same-owner packing produces) eats real splash; a spread one barely | |
| // does. Enemy-only and units-only by construction (performAttack only ever walks | |
| // state.unitGrid/state.units, never state.buildings) β never the Colossus's own side, and | |
| // never a building, however close one stands to the impact. Tuned against | |
| // test/balance.test.js the same way SALVAGE_FRAC once was: the Skiff counter (below) must | |
| // still win head-on, cost-parity, splash included. | |
| splash: { radius: 26, frac: 0.5 }, | |
| // "must be screened or it's sniped/rushed down" above, certified by | |
| // test/balance.test.js's supply-parity duel: fragile and slow-firing, it | |
| // folds to a mass it can't kill fast enough. | |
| counteredBy: "skiff", | |
| requires: ["arsenal"], | |
| }, | |
| leviathan: { | |
| id: "leviathan", name: "Leviathan", hp: 900, radius: 14, speed: 34, | |
| cost: { ore: 300, ai: 3, plasmatorp: 4 }, buildTime: 60, supplyCost: 8, | |
| role: "combat", attack: 70, range: 200, cooldown: 2.4, | |
| sight: 220, aggroRange: 190, | |
| // The Strategic-tier capital ship: built only at a Star Dock (Odyssey-only), | |
| // costed in AI Cores + Plasma Torpedoes you must MANUFACTURE β the military | |
| // half of the endgame. Huge hull, long reach, a base-cracker's structure | |
| // bonus. Deliberately OUTSIDE the rock-paper-scissors triangle (no bonusVs, | |
| // nothing hard-counters it) β but 8 supply and a strategic-good price mean a | |
| // swarm of cheap units still trades into it, and every good spent on a | |
| // Leviathan is one not fed to the Antimatter Gate. | |
| bonusVsBuildings: 40, | |
| requires: ["stardock"], | |
| odysseyOnly: true, // the endgame capital ship deliberately exceeds the skirmish specialists (see entities.test) | |
| }, | |
| heliumbomb: { | |
| id: "heliumbomb", name: "Helium Bomb", hp: 80, radius: 10, speed: 60, | |
| cost: { gas: 150, antimatter: 3, plasmatorp: 3 }, buildTime: 45, supplyCost: 3, | |
| // A doomsday device, not a combatant: no `attack`, and role "bomb" (not | |
| // "combat") means it never auto-fights and the AI's army/threat scans | |
| // (which filter role === "combat") skip right past it, the same way they | |
| // skip the Colony Ship. It's still a perfectly legal target for an ENEMY's | |
| // own auto-acquire β combat.js's targeting picks the nearest live enemy of | |
| // any role, not just role:"combat" β so shooting an armed one is the worst | |
| // possible move (see engine/bomb.js: any hit on an armed bomb detonates it | |
| // instead of just damaging it). | |
| role: "bomb", sight: 150, | |
| requires: ["stardock"], | |
| odysseyOnly: true, | |
| }, | |
| }; | |
| // Every commodity that anything (a unit, a building, an upgrade) actually costs β computed | |
| // once. Shared by aiWorkers.js's assignIdleWorkers (so a poor-economy world's AI, e.g. Glacius' | |
| // ice/gas it can never spend, prefers nodes it can actually use) and market.js's AI barter (so | |
| // it never treats an already-dead-end commodity as a real "bottleneck" to trade toward). | |
| export const SPENDABLE = (() => { | |
| const coms = new Set(); | |
| for (const d of [...Object.values(UNITS), ...Object.values(BUILDINGS), ...Object.values(UPGRADES)]) | |
| for (const com of Object.keys(d.cost || {})) coms.add(com); | |
| return coms; | |
| })(); | |
| export function canAfford(resources, cost) { | |
| return Object.entries(cost).every(([com, qty]) => (resources[com] || 0) >= qty); | |
| } | |
| // Clamp to a range. Byte-identical private copies lived in diplomacy.js and market.js, both on | |
| // determinism-sensitive paths (stance drift and price math), so one being "fixed" without the other | |
| // is a divergence no single-module suite would see. | |
| /** @param {number} v @param {number} lo @param {number} hi @returns {number} */ | |
| export function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } | |
| // The refund delta for a cost bag. Lives here, beside payCost, because BOTH refund paths need it: | |
| // engine/production.js (a cancelled unit) and engine/techtree.js (a cancelled research) each used to | |
| // carry a byte-identical private copy, so a rounding or negative-zero fix applied to one and not the | |
| // other would be a silent economy asymmetry no single-module suite could see. | |
| /** @param {Resources} cost @returns {Resources} */ | |
| export function negate(cost) { | |
| return Object.fromEntries(Object.entries(cost).map(([com, qty]) => [com, -qty])); | |
| } | |
| // Does `owner` have a FINISHED building of this type? The prereq question both UI surfaces ask β | |
| // overlays.js's help panel and techChart.js's ladder β which used to carry the same body under two | |
| // different names (hasBuilding / hasCompletedBuilding), which is why the duplication went unnoticed. | |
| /** @param {State} state @param {string} owner @param {string} type @returns {boolean} */ | |
| export function hasCompletedBuilding(state, owner, type) { | |
| for (const b of state.buildings.values()) if (b.owner === owner && b.type === type && !b.constructing) return true; | |
| return false; | |
| } | |
| export function payCost(resources, cost) { | |
| Object.entries(cost).forEach(([com, qty]) => { resources[com] = (resources[com] || 0) - qty; }); | |
| } | |