Spaces:
Paused
Paused
File size: 17,473 Bytes
fb3a2d7 4b95948 fb3a2d7 4b95948 fb3a2d7 4b95948 fb3a2d7 e847942 9bb52d4 fb3a2d7 28e5839 fb3a2d7 28e5839 fb3a2d7 28e5839 fb3a2d7 af15726 28e5839 af15726 28e5839 fb3a2d7 28e5839 fb3a2d7 28e5839 fb3a2d7 15e011a 28e5839 15e011a b50413b 28e5839 b50413b 6f39bc5 79c7af7 26b3c86 fb3a2d7 af15726 fb3a2d7 af15726 fb3a2d7 916c3ad 9bb52d4 916c3ad 9bb52d4 4b95948 fb3a2d7 4b95948 e46bcce 4b95948 e46bcce 9bb52d4 fb3a2d7 e46bcce fb3a2d7 e46bcce 4b95948 fb3a2d7 e46bcce fb3a2d7 e46bcce fb3a2d7 e46bcce fb3a2d7 af15726 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | /* ============================================================
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;
}
|