SpaceCities / engine /techtree.js
Claude
Claude Opus 5
Tier 2a: collapse the cross-module duplicates, pin the tie-breaks
e847942 unverified
Raw History Blame Contribute Delete
17.5 kB
/* ============================================================
The queued, TIMED research loop β€” shared by the Odyssey tech tree (TECHS, below;
researched at a Datacenter) and the Refinery's doctrine upgrades (UPGRADES,
engine/entities.js; researched in every mode, skirmish included). updateResearch
resolves WHICH node table applies purely from building.type, so both run the
SAME dt-driven development loop instead of two near-duplicate ones β€” see
RESEARCH_TABLE_BY_BUILDING below. Either way, a node is PAID IN GATHERED
COMMODITIES (the game's law β€” the resources you gather are the resources you
spend, no separate research currency) and DEVELOPED OVER TIME, scaled by the
world's tech rating so a Syndicate hub out-researches a frontier rock β€” which,
for a doctrine upgrade, now makes a SKIRMISH world's own tech rating matter too.
A completed node lands as an id in player.upgrades β€” the SAME bag either table
reads back from β€” so it gates buildings/recipes through the existing prereqsMet
primitive with zero new gating machinery. committedDoctrine/upgradeMult
(entities.js) both guard on UPGRADES membership specifically, so a TECHS id
parked in upgrades is invisible to the doctrine system; the passive-effect TECHS
nodes are read here via techMult instead.
The TECHS half stays Odyssey-only and inert-by-construction: the Datacenter is
`odysseyOnly` and the skirmish AI never builds one, so the skirmish sim/AI path
is untouched there (a TECHS id is never in a skirmish player.upgrades). The
Refinery half is available β€” and now timed, where it used to be instant-on-
payment β€” in BOTH modes; see production.js's researchUpgrade for its enqueue
side. Deterministic and DOM-free either way: accrual is dt-driven float math
with no wall-clock and no unseeded randomness.
============================================================ */
"use strict";
import { PLANETS } from "../data.js";
import { canAfford, payCost, prereqsMet, UPGRADES, negate } from "./entities.js";
import { difficultyFor } from "./aiDifficulty.js";
// The tree. `cost` is gathered commodities (paid on start); `time` is seconds to
// develop at a tech-5 world (scaled by researchTimeScale). `requires` are prereq
// tokens resolved by prereqsMet (a building type or another node). A node either
// UNLOCKS content (its id gates a building/recipe via `requires` elsewhere) or is
// a PASSIVE that multiplies industry (powerMult / rateMult / yieldMult, read by
// techMult). The three unlock nodes form the buildable spine; the three passives
// branch off it so "research next building" vs "boost what I have" is a real fork.
// `ico` reuses the data.js commodity icons where a node maps to a good (metals ⛓️, electronics
// πŸ–₯️, antimatter πŸŒ€, AI cores 🧠), or a thematic emoji for the pure boosts β€” so the research
// buttons carry the same iconography as the rest of the game (hud.js).
export const TECHS = {
metallurgy: { id: "metallurgy", name: "Metallurgy", ico: "⛓️", cost: { crystals: 80 }, time: 20,
desc: "Unlock the Assembly Plant β€” refine metals into alloys." },
reactors: { id: "reactors", name: "Fusion Containment", ico: "⚑", cost: { crystals: 70 }, time: 18,
powerMult: 1.5, desc: "+50% Power from every Reactor." },
// appliesTo scopes the passive to exactly the building types its own tooltip names β€” without
// it, techMult would apply yieldMult to EVERY recipe building (Chip Fab, Antimatter Forge, …),
// silently over-boosting strategic-good throughput far past this cheap early node's advertised
// role. Nodes with no appliesTo field (reactors/automation above) stay global, unchanged.
heavyalloys: { id: "heavyalloys", name: "Heavy Alloys", ico: "πŸ”©", cost: { crystals: 110 }, time: 24, requires: ["metallurgy"],
yieldMult: 1.4, appliesTo: ["smelter", "assembler"], desc: "+40% output from the Smelter and Assembly Plant." },
electronics: { id: "electronics", name: "Microelectronics", ico: "πŸ–₯️", cost: { crystals: 120 }, time: 28, requires: ["metallurgy"],
desc: "Unlock the Chip Fab β€” make electronics from crystals and metals." },
automation: { id: "automation", name: "Factory Automation", ico: "πŸ€–", cost: { crystals: 130, radioactives: 40 }, time: 30, requires: ["electronics"],
rateMult: 1.25, desc: "+25% production speed at every factory." },
machining: { id: "machining", name: "Precision Machining", ico: "πŸ› οΈ", cost: { crystals: 150, radioactives: 60 }, time: 36, requires: ["electronics"],
desc: "Unlock the Machine Works β€” build machinery from alloys and electronics." },
// The strategic capstone (Phase 3): unlocks the Antimatter Forge, and with it the
// Antimatter Gate β€” Odyssey's endgame. The deepest, priciest node on the tree.
antimatter: { id: "antimatter", name: "Antimatter Containment", ico: "πŸŒ€", cost: { crystals: 220, radioactives: 100 }, time: 44, requires: ["machining"],
desc: "Unlock the Antimatter Forge β€” the top of the chain, and the key to the Antimatter Gate." },
// The full Strategic tier: the two remaining strategic goods, which fuel the
// Antimatter Gate and the Leviathan capital ship.
aicores: { id: "aicores", name: "Machine Minds", ico: "🧠", cost: { crystals: 260, radioactives: 130 }, time: 48, requires: ["antimatter"],
desc: "Unlock the AI Foundry and Torpedo Works β€” cultivate AI Cores and Plasma Torpedoes." },
// engine/haul.js FREIGHTER_AI_TECH β€” the string id here MUST match that constant. An unlock node
// like metallurgy/electronics/machining above (no multiplier field: it gates a CAPABILITY, read
// directly off player.upgrades by id in sim.js/commands.js/hudSelection.js, not through techMult).
// Priced partly in the very good it lets a freighter burn to run itself (engine/haul.js
// payAIUpkeep) β€” cultivating the Cores to spend on automation IS the cost of automating.
freighterai: { id: "freighterai", name: "Autonomous Freight AI", ico: "🧠", cost: { ai: 60, electronics: 90 }, time: 40, requires: ["aicores"],
desc: "Freighters can be toggled into the local logistics chain like a worker β€” far greater capacity per trip β€” while burning AI Cores to run themselves." },
// engine/recycle.js RECYCLE_TECH β€” the string id here MUST match that constant, and matches the
// skirmish doctrine tree's OWN "recycling" node (engine/entities.js UPGRADES) too: only one of
// the two trees is ever active in a given match, so sharing the id is safe, and it's what lets
// recycleFrac check a single player.upgrades flag regardless of mode. No multiplier field β€”
// gates recycleFrac's research bonus directly by id, same as freighterai above.
recycling: { id: "recycling", name: "Reclamation Engineering", ico: "♻️", cost: { crystals: 140, radioactives: 40 }, time: 30, requires: ["automation"],
desc: "+30% of a recycled unit/building's cost reclaimed (up to an 80% cap)." },
// Promote the legacy consumer-goods recipes into a trade-industry branch (docs/improvement-
// proposals.md lines 443-451): data.js RECIPES already carries 'chem' (biomass+power ->
// chemicals) and 'consumer' (alloys+chemicals+power -> goods) as documented legacy β€” "no
// producer". These two nodes are the exact "tech + wiring" promotion path its own LIVE-vs-LEGACY
// comment reserves. `chemistry` is deliberately a ROOT node β€” no `requires` at all, the same
// shape as `metallurgy` above β€” so it opens a genuinely SEPARATE branch off the Datacenter
// (biomass/spice worlds get an industrial identity of their own) rather than a leaf hanging off
// the existing ore->metals->alloys spine. `consumerfab` is gated on `chemistry` alone (its own
// branch's root, not metallurgy) β€” see engine/entities.js BUILDINGS.chemplant/fabricator for
// where the two branches actually MEET (the Fabricator building itself needs an Assembly Plant
// for alloys), and note this against data.js's LIVE-vs-LEGACY comment.
chemistry: { id: "chemistry", name: "Industrial Chemistry", ico: "βš—οΈ", cost: { crystals: 80 }, time: 20,
desc: "Unlock the Chemical Plant β€” refine biomass into chemicals." },
consumerfab: { id: "consumerfab", name: "Consumer Fabrication", ico: "πŸ“¦", cost: { crystals: 150, radioactives: 60 }, time: 36, requires: ["chemistry"],
desc: "Unlock the Fabricator β€” combine alloys and chemicals into consumer goods." },
};
// Research develops faster on a high-tech world (data.js PLANETS.tech, 1..10) and
// slower on a frontier rock β€” clamped so no world is punishing. Pure data lookup;
// the sole reason WHERE you research is a strategic choice. Deterministic.
export function researchTimeScale(state) {
const t = PLANETS.find(p => p.id === state.planetId)?.tech ?? 5;
const s = 5 / t; // tech 5 β†’ 1.0Γ—, tech 10 β†’ 0.5Γ—, tech 1 β†’ 5Γ— (clamped)
return s < 0.5 ? 0.5 : s > 2 ? 2 : s;
}
// Product of a passive node's multiplier field across a player's researched techs
// (1 when none apply) β€” the tech-tree twin of entities.js upgradeMult, reading
// TECHS instead of UPGRADES. Inert in skirmish (no TECH id is ever in a skirmish
// player.upgrades).
//
// `buildingType` is optional and scopes a node whose def carries an `appliesTo` list (e.g.
// heavyalloys -> ["smelter","assembler"]) to only the types it names β€” a node with no appliesTo
// field stays global regardless. Passing NO buildingType (the plain 2-arg form) skips the
// appliesTo check entirely and returns the un-scoped product, so every pre-existing call site
// keeps its old behavior untouched; only a caller that names the asking building (industry.js
// updateProduction's yieldMult site, hudSelection.js's mirrored rate preview) gets the scoping.
export function techMult(upgrades, field, buildingType) {
let m = 1;
if (!upgrades) return m;
for (const id in upgrades) {
if (!upgrades[id]) continue;
const def = TECHS[id];
if (!def || !def[field]) continue;
if (def.appliesTo && buildingType !== undefined && !def.appliesTo.includes(buildingType)) continue;
m *= def[field];
}
return m;
}
// Difficulty's research-pace dial (engine/aiDifficulty.js), applied to an AI CONTROLLER's own
// Datacenter/Refinery research only β€” never a human player's, even on the same tech-rated world.
// researchTimeScale above is deliberately per-WORLD (a Syndicate hub is fast for whoever researches
// there); this is the one further, per-OWNER layer on top of it. owner "ai" always reads its own
// difficulty (state.ai.difficulty) β€” unchanged. owner "player" reads state.playerAi's difficulty
// ONLY when Tier 1 self-play (tools/selfplay.js) has actually populated it β€” a human-driven
// "player" owner (every match that exists outside self-play) has no controller there, so this
// still resolves to 1 (a no-op), exactly as before. Without this owner-aware read, a self-play
// "player" controller's own chosen difficulty would silently never apply its research-pace dial β€”
// the same class of fairness bug fog-omniscience is, just for a timing dial instead of vision.
function aiResearchPaceMult(state, owner) {
if (owner !== "ai" && (owner !== "player" || !state.playerAi)) return 1;
return difficultyFor(state, owner).researchPaceMult || 1;
}
// Which building type researches from which node table β€” the one thing that
// differs between the Datacenter's tech tree and the Refinery's doctrine
// upgrades; everything else in updateResearch below is shared verbatim. Any
// other building type (or one whose researchQueue was mis-set by a tampered
// save) simply isn't a research building at all.
const RESEARCH_TABLE_BY_BUILDING = { datacenter: TECHS, refinery: UPGRADES };
// Advance a research building's QUEUE by dt β€” a no-op for anything that isn't a
// completed Datacenter or Refinery with a queued job (RESEARCH_TABLE_BY_BUILDING
// resolves which node table this building's ids belong to). Develops the head of
// the queue; on completion the node lands in player.upgrades (where
// prereqsMet/techMult/upgradeMult read it), the job is dropped, and the next
// queued node begins. Same dt-driven float pattern as buildProgress β€”
// deterministic, engine-pure.
export function updateResearch(state, building, dt) {
const table = RESEARCH_TABLE_BY_BUILDING[building.type];
if (!table || building.constructing) return;
const queue = building.researchQueue;
if (!queue || queue.length === 0) return;
const job = queue[0];
const def = table[job.techId];
if (!def) { queue.shift(); return; }
job.progress += dt / (def.time * researchTimeScale(state) * aiResearchPaceMult(state, building.owner));
if (job.progress >= 1) {
state.players[building.owner].upgrades[job.techId] = true;
queue.shift();
state.events.push({ type: "researchComplete", techId: def.id, x: building.x, y: building.y, owner: building.owner });
}
}
// Queue a node for research at a Datacenter: needs a completed Datacenter, the node
// not already owned or queued, its prereqs met OR already queued AHEAD of it (so a
// whole path can be lined up at once and stop being babysat one node at a time),
// and affordability β€” then pay (gathered commodities, on enqueue like the
// production queue) and append it. Mirrors production.js researchUpgrade β€” both are
// timed and queued now β€” except a Datacenter's OWN queued-ahead node counts as a
// met prereq here; a Refinery's Tier-2 doctrine upgrade deliberately does NOT get
// that same relaxation (it needs its Tier-1 actually complete, not just queued β€”
// see researchUpgrade), since a queued Tier-1 is also what commits the doctrine
// lock (entities.js committedDoctrine), and letting an unfinished Tier-1 unlock its
// own Tier-2 would let a player queue an entire doctrine's cost before the first
// upgrade even proves out.
export function researchTech(state, buildingId, techId) {
const building = state.buildings.get(buildingId);
if (!building || building.type !== "datacenter" || building.constructing) return false;
const player = state.players[building.owner];
if (player.upgrades[techId]) return false; // already researched
const queue = building.researchQueue || (building.researchQueue = []);
if (queue.some(j => j.techId === techId)) return false; // already queued
const def = TECHS[techId];
if (!def) return false;
// A prereq counts as met if it's researched, a completed building, OR queued
// ahead on this same Datacenter (it'll finish first). prereqsMet covers the first
// two; filter out the queued-ahead tokens before delegating to it.
const queuedAhead = new Set(queue.map(j => j.techId));
const remaining = (def.requires || []).filter(r => !queuedAhead.has(r));
if (!prereqsMet(state, building.owner, { requires: remaining })) return false;
if (!canAfford(player.resources, def.cost)) return false;
payCost(player.resources, def.cost);
queue.push({ techId, progress: 0 });
return true;
}
// Negate a cost map so payCost can be used to REFUND it β€” the 2-line helper production.js's own
// cancelProduction uses, duplicated locally rather than imported: techtree.js already sits
// upstream of production.js in the import graph (production.js -> industry.js -> techtree.js for
// techMult), so importing back the other way would open a cycle.
// Cancel a queued research job (in progress or still waiting, either one β€” mirrors
// production.js's cancelProduction: the simplest, most player-friendly convention, and consistent
// with nothing but the commodity cost having been spent on it yet) and fully refund it. If any
// OTHER queued job on the SAME building named the cancelled techId in its own `requires`, cancel
// and refund that too, cascading (a chain of 3+ nodes cascades all the way through) β€” researchTech
// only ever lets a dependent queue AHEAD of an unmet prereq by way of the prereq ALREADY sitting in
// the queue, so a job can only ever depend on something queued before it, never after; the queue
// can then never be left holding a job whose prereq just vanished out from under it.
export function cancelResearch(state, buildingId, index) {
const building = state.buildings.get(buildingId);
if (!building) return false;
const table = RESEARCH_TABLE_BY_BUILDING[building.type];
const queue = building.researchQueue;
if (!table || !queue || !queue[index]) return false;
const player = state.players[building.owner];
// Cancel one job by queue index: refund its cost (nothing to refund for a stale/unresolvable
// techId β€” updateResearch would have silently dropped it anyway, so nothing was ever banked
// against it here) and splice it out. Returns the cancelled techId so the caller can track it.
const cancelOne = i => {
const job = queue[i];
const def = table[job.techId];
queue.splice(i, 1);
if (def) payCost(player.resources, negate(def.cost));
return job.techId;
};
const cancelledIds = new Set([cancelOne(index)]);
let again = true;
while (again) {
again = false;
for (let i = 0; i < queue.length; i++) {
const def = table[queue[i].techId];
if (def && (def.requires || []).some(r => cancelledIds.has(r))) {
cancelledIds.add(cancelOne(i));
again = true;
break; // the queue just shrank under us β€” restart the scan from the top
}
}
}
return true;
}