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;
}