Claude Claude Opus 4.8 commited on
Commit
08cde58
·
unverified ·
1 Parent(s): 6772f54

Combustion Generator: cheap gas/biomass power on a tight grid

Browse files

A fuel-burning alternative to the Reactor. It grants half the Power (energyGrants 10
vs 20) over a much smaller grid (powerRange 0.55 shrinks its efficiency zones), and
only while it's fed — it burns gas OR biomass (whichever the stockpile has more of) at
combust.rate/sec from the treasury, the mirror of the Reactor's free-but-pricey grid:
cheap to raise, ongoing fuel bill. Out of fuel or paused, it grants nothing.

- entities.js: `combustor` def (energyGrants 10, powerRange 0.55, combust spec); the
Reactor gains powerRange 1 as the reference reach.
- industry.js: powerEfficiency now scales each source's distance by its powerRange
(nearestReactorDist → bestGridDist), so a Generator's zones sit tight and a consumer
must huddle close; powerCap and the grid only count ACTIVE sources; updateCombustors
burns fuel each tick and sets the transient `powered`/`fuel`.
- sim.js: settle Generators before any consumer reads powerCap.
- persist.js: strip the transient powered/fuel from saves.
- render.js: the efficiency-zone rings (selected source + build-ghost cue) scale by
powerRange; 🔥 map glyph.
- hud.js: build-menu entry + a selected-Generator panel (grants/fuel/stalled + a pause
toggle to stop the fuel bill).

Deterministic (fuel-gated), Odyssey-only. Tests: test/combustor.test.js (fuelled-only
power, gas|biomass fallback, smaller grid, no false on-grid off a dead generator, save-
clean determinism). Full suite 596 green, tsc clean, browser-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEQUVetLk31Jj7niDQAsXL

Files changed (8) hide show
  1. engine/entities.js +13 -0
  2. engine/industry.js +43 -12
  3. engine/persist.js +4 -4
  4. engine/sim.js +4 -1
  5. engine/types.js +2 -0
  6. hud.js +28 -2
  7. render.js +26 -19
  8. test/combustor.test.js +90 -0
engine/entities.js CHANGED
@@ -98,6 +98,19 @@ export const BUILDINGS = {
98
  id: "reactor", name: "Reactor", hp: 400, radius: 16,
99
  cost: { ore: 120 }, buildTime: 16, sight: 130,
100
  energyGrants: 20, // the power that runs the factories below; short power throttles them all
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  odysseyOnly: true,
102
  },
103
  smelter: {
 
98
  id: "reactor", name: "Reactor", hp: 400, radius: 16,
99
  cost: { ore: 120 }, buildTime: 16, sight: 130,
100
  energyGrants: 20, // the power that runs the factories below; short power throttles them all
101
+ powerRange: 1, // the reference grid reach — a consumer's efficiency tier is its distance / this (industry.js)
102
+ odysseyOnly: true,
103
+ },
104
+ combustor: {
105
+ id: "combustor", name: "Combustion Generator", hp: 300, radius: 13,
106
+ cost: { ore: 70 }, buildTime: 12, sight: 110,
107
+ // A cheap, fuel-burning alternative to the Reactor: it GRANTS less Power (energyGrants 10 vs 20)
108
+ // over a SMALLER grid (powerRange 0.55 shrinks its efficiency zones — industry.js), and only while
109
+ // it's fed. It burns gas OR biomass from the treasury (combust.rate/sec, whichever the stockpile has)
110
+ // — so it's cheap to raise but carries an ongoing fuel bill, the mirror of the Reactor's free-but-
111
+ // pricey grid. Out of fuel (or paused) it grants nothing. See engine/industry.js updateCombustors.
112
+ energyGrants: 10, powerRange: 0.55,
113
+ combust: { fuels: ["gas", "biomass"], rate: 0.6 },
114
  odysseyOnly: true,
115
  },
116
  smelter: {
engine/industry.js CHANGED
@@ -54,31 +54,58 @@ export const POWER_TIERS = [
54
  { name: "isolated", max: Infinity, mult: 2.3, label: "Isolated" },
55
  ];
56
 
57
- // The nearest COMPLETED own Reactor's distance from (x,y), or Infinity if the owner
58
- // has none. Guards non-finite coordinates (the industry unit-test stubs omit x/y) so
59
- // they read as co-located rather than NaN-poisoning the scan.
60
- function nearestReactorDist(state, owner, x, y) {
 
 
 
 
 
 
 
 
61
  let best = Infinity;
62
  for (const b of state.buildings.values()) {
63
  if (b.owner !== owner || b.constructing) continue;
64
- if (!(BUILDINGS[b.type]?.energyGrants > 0)) continue;
65
- const d = Math.hypot(b.x - x, b.y - y);
 
66
  if (Number.isFinite(d) && d < best) best = d;
67
  }
68
  return best;
69
  }
70
 
71
- // The grid-efficiency tier of a spot for `owner`: the band its distance to the nearest
72
- // Reactor falls in. No Reactor (or a non-positional stub) → the neutral 'linked' tier
73
- // (×1): there's no grid to lose against, and powerThrottle already zeroes an unpowered
74
  // consumer, so an isolated penalty would be moot there anyway.
75
  export function powerEfficiency(state, owner, x, y) {
76
- const d = nearestReactorDist(state, owner, x, y);
77
  if (!Number.isFinite(d)) return POWER_TIERS[0];
78
  for (const t of POWER_TIERS) if (d <= t.max) return t;
79
  return POWER_TIERS[POWER_TIERS.length - 1];
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  // data.js RECIPES is an array; index it by id once for O(1) lookup.
83
  const RECIPE_BY_ID = Object.fromEntries(RECIPES.map(r => [r.id, r]));
84
 
@@ -92,8 +119,12 @@ export function recipeOf(building) {
92
  // by the Fusion Containment tech (techtree.js `reactors` node, +50%) if researched.
93
  export function powerCap(state, owner) {
94
  let cap = 0;
95
- for (const b of state.buildings.values())
96
- if (b.owner === owner && !b.constructing) cap += BUILDINGS[b.type].energyGrants || 0;
 
 
 
 
97
  return cap * techMult(state.players[owner]?.upgrades, "powerMult");
98
  }
99
 
 
54
  { name: "isolated", max: Infinity, mult: 2.3, label: "Isolated" },
55
  ];
56
 
57
+ // Whether a power SOURCE is actually feeding the grid right now: a Reactor always is; a
58
+ // fuel-burning Generator only while it's fed (engine/industry.js updateCombustors sets `powered`).
59
+ function sourceActive(building, def) {
60
+ return def.combust ? !!building.powered : true;
61
+ }
62
+
63
+ // The best (smallest) grid-distance from (x,y) to any ACTIVE own power source, each source's
64
+ // distance divided by its `powerRange` — so a short-range Generator's efficiency zones shrink and
65
+ // a consumer must huddle much closer to it than to a Reactor. Infinity if the owner has no active
66
+ // source in reach. Guards non-finite coordinates (the industry unit-test stubs omit x/y) so they
67
+ // read as co-located rather than NaN-poisoning the scan.
68
+ function bestGridDist(state, owner, x, y) {
69
  let best = Infinity;
70
  for (const b of state.buildings.values()) {
71
  if (b.owner !== owner || b.constructing) continue;
72
+ const def = BUILDINGS[b.type];
73
+ if (!(def?.energyGrants > 0) || !sourceActive(b, def)) continue;
74
+ const d = Math.hypot(b.x - x, b.y - y) / (def.powerRange || 1);
75
  if (Number.isFinite(d) && d < best) best = d;
76
  }
77
  return best;
78
  }
79
 
80
+ // The grid-efficiency tier of a spot for `owner`: the band its (range-scaled) distance to the
81
+ // nearest active power source falls in. No source (or a non-positional stub) → the neutral 'linked'
82
+ // tier (×1): there's no grid to lose against, and powerThrottle already zeroes an unpowered
83
  // consumer, so an isolated penalty would be moot there anyway.
84
  export function powerEfficiency(state, owner, x, y) {
85
+ const d = bestGridDist(state, owner, x, y);
86
  if (!Number.isFinite(d)) return POWER_TIERS[0];
87
  for (const t of POWER_TIERS) if (d <= t.max) return t;
88
  return POWER_TIERS[POWER_TIERS.length - 1];
89
  }
90
 
91
+ // Burn one tick of fuel for every Combustion Generator: it draws combust.rate/sec of gas OR biomass
92
+ // (whichever the treasury has more of) and is `powered` only while fed — paused or dry, it grants no
93
+ // Power. Runs at tick start, before anything reads powerCap, so the grid it provides is settled for
94
+ // the tick. Deterministic (fuel-gated); a no-op for any building without a `combust` def.
95
+ export function updateCombustors(state, dt) {
96
+ for (const b of state.buildings.values()) {
97
+ const def = BUILDINGS[b.type];
98
+ if (!def?.combust || b.constructing) continue;
99
+ const res = state.players[b.owner]?.resources;
100
+ if (b.paused || !res) { b.powered = false; continue; }
101
+ const need = def.combust.rate * dt;
102
+ let fuel = null, most = 0;
103
+ for (const f of def.combust.fuels) { const have = res[f] || 0; if (have > most) { most = have; fuel = f; } }
104
+ if (fuel && (res[fuel] || 0) >= need) { res[fuel] -= need; b.powered = true; b.fuel = fuel; }
105
+ else b.powered = false;
106
+ }
107
+ }
108
+
109
  // data.js RECIPES is an array; index it by id once for O(1) lookup.
110
  const RECIPE_BY_ID = Object.fromEntries(RECIPES.map(r => [r.id, r]));
111
 
 
119
  // by the Fusion Containment tech (techtree.js `reactors` node, +50%) if researched.
120
  export function powerCap(state, owner) {
121
  let cap = 0;
122
+ for (const b of state.buildings.values()) {
123
+ if (b.owner !== owner || b.constructing) continue;
124
+ const def = BUILDINGS[b.type];
125
+ if (!(def.energyGrants > 0) || !sourceActive(b, def)) continue; // an unfueled Generator grants nothing
126
+ cap += def.energyGrants;
127
+ }
128
  return cap * techMult(state.players[owner]?.upgrades, "powerMult");
129
  }
130
 
engine/persist.js CHANGED
@@ -168,10 +168,10 @@ function serPlanet(state) {
168
  // by buildUnitGrid, meaningless once saved. Strip it so it doesn't bloat the payload with a
169
  // per-unit integer that the next tick overwrites anyway. Shallow copy, only at save time.
170
  units: [...state.units.values()].map(({ _gi, ...u }) => u),
171
- // `haulers`/`suppliers` are the transient per-tick logistics tallies (engine/haul.js),
172
- // stamped fresh each tick like a unit's `_gi` grid index — strip them so they never bloat
173
- // or drift a save.
174
- buildings: [...state.buildings.values()].map(({ haulers, suppliers, ...b }) => b),
175
  nodes: state.map.nodes.map(n => ({ id: n.id, amount: n.amount })),
176
  fog: [...state.fog.explored],
177
  fogAI: [...state.fogAI.explored],
 
168
  // by buildUnitGrid, meaningless once saved. Strip it so it doesn't bloat the payload with a
169
  // per-unit integer that the next tick overwrites anyway. Shallow copy, only at save time.
170
  units: [...state.units.values()].map(({ _gi, ...u }) => u),
171
+ // `haulers`/`suppliers` are the transient per-tick logistics tallies (engine/haul.js), and
172
+ // `powered`/`fuel` are the Generator's per-tick fuel state (engine/industry.js) — all stamped
173
+ // fresh each tick like a unit's `_gi` grid index, so strip them (they never bloat or drift a save).
174
+ buildings: [...state.buildings.values()].map(({ haulers, suppliers, powered, fuel, ...b }) => b),
175
  nodes: state.map.nodes.map(n => ({ id: n.id, amount: n.amount })),
176
  fog: [...state.fog.explored],
177
  fogAI: [...state.fogAI.explored],
engine/sim.js CHANGED
@@ -14,7 +14,7 @@ import { updateScoutMode } from "./scout.js";
14
  import { updateRepair } from "./repair.js";
15
  import { updateCombat, updateBuildingCombat, updateWorkerCombat } from "./combat.js";
16
  import { updateBuildingConstruction, updateProductionQueue, BUILD_REACH } from "./production.js";
17
- import { updateProduction } from "./industry.js";
18
  import { updatePlasmaRig } from "./rig.js";
19
  import { updateResearch } from "./techtree.js";
20
  import { updateWonder } from "./wonder.js";
@@ -47,6 +47,9 @@ export function tick(state, dt) {
47
  // assigned a logistics job below — so the "≤2 per building" caps read the same regardless
48
  // of Map iteration order (determinism). A no-op in skirmish: nothing there has a buffer.
49
  countLogistics(state);
 
 
 
50
  // Aura projectors (Aegis) for this tick, read by combat.js attackDamage. Collected
51
  // once here (a tiny list — the units are Tier-3 and rare) so a landed hit costs O(anvils)
52
  // not O(units); positions are frozen at tick start like the grid above (deterministic).
 
14
  import { updateRepair } from "./repair.js";
15
  import { updateCombat, updateBuildingCombat, updateWorkerCombat } from "./combat.js";
16
  import { updateBuildingConstruction, updateProductionQueue, BUILD_REACH } from "./production.js";
17
+ import { updateProduction, updateCombustors } from "./industry.js";
18
  import { updatePlasmaRig } from "./rig.js";
19
  import { updateResearch } from "./techtree.js";
20
  import { updateWonder } from "./wonder.js";
 
47
  // assigned a logistics job below — so the "≤2 per building" caps read the same regardless
48
  // of Map iteration order (determinism). A no-op in skirmish: nothing there has a buffer.
49
  countLogistics(state);
50
+ // Settle the fuel-burning Generators' Power BEFORE any consumer reads powerCap this tick,
51
+ // so the grid a factory/rig sees is stable. A no-op without a Combustion Generator.
52
+ updateCombustors(state, dt);
53
  // Aura projectors (Aegis) for this tick, read by combat.js attackDamage. Collected
54
  // once here (a tiny list — the units are Tier-3 and rare) so a landed hit costs O(anvils)
55
  // not O(units); positions are frozen at tick start like the grid above (deterministic).
engine/types.js CHANGED
@@ -119,6 +119,8 @@
119
  * @property {Object.<string, number>} [input] a factory's finite input larder, commodity → qty (engine/haul.js)
120
  * @property {number} [haulers] transient per-tick count of workers hauling from this producer (engine/haul.js)
121
  * @property {number} [suppliers] transient per-tick count of workers supplying this factory (engine/haul.js)
 
 
122
  */
123
 
124
  /**
 
119
  * @property {Object.<string, number>} [input] a factory's finite input larder, commodity → qty (engine/haul.js)
120
  * @property {number} [haulers] transient per-tick count of workers hauling from this producer (engine/haul.js)
121
  * @property {number} [suppliers] transient per-tick count of workers supplying this factory (engine/haul.js)
122
+ * @property {boolean} [powered] transient: a Combustion Generator is fed & granting Power this tick (engine/industry.js)
123
+ * @property {string} [fuel] transient: which fuel the Generator burned this tick (HUD)
124
  */
125
 
126
  /**
hud.js CHANGED
@@ -384,6 +384,11 @@ function renderSelectionPanel() {
384
  const d = sel.find(e => e.kind === "building" && e.owner === "player" && !e.constructing
385
  && storeCapOf(e.type) > 0 && !BUILDINGS[e.type].recipe && !BUILDINGS[e.type].isCommandCenter);
386
  return d ? Math.round((storeTotal(d) / (storeCapOf(d.type) || 1)) * 10) : "";
 
 
 
 
 
387
  })();
388
 
389
  if (signature !== lastPanelSignature) {
@@ -974,6 +979,27 @@ function rebuildSelectionPanel(sel) {
974
  panelEl.appendChild(note);
975
  }
976
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
  // Forward drop-off (Refinery / Foundry / Arsenal): a finite INTAKE buffer gatherers bank
978
  // raw hauls into (engine/gather.js). When it fills, gatherers reroute elsewhere until a
979
  // worker clears it to the Command Center — so surface how full it is and whether it's full.
@@ -1099,14 +1125,14 @@ function rebuildSelectionPanel(sel) {
1099
  // tier would bury the menu), mirroring how the Barracks hides units you can't yet
1100
  // field.
1101
  const GROUPS = [
1102
- ["Economy", ["reactor", "smelter", "datacenter", "assembler", "chipfab",
1103
  "machineworks", "antimatterforge", "aifoundry", "torpedoworks", "plasmarig"]],
1104
  ["Military", ["barracks", "foundry", "arsenal", "refinery", "turret", "habitat", "stardock"]],
1105
  ["Endgame", ["antimatter_gate"]],
1106
  ["Travel", ["spaceport"]],
1107
  ];
1108
  const alwaysShow = new Set(["barracks", "foundry", "arsenal", "refinery", "turret",
1109
- "habitat", "reactor", "smelter", "datacenter", "spaceport"]);
1110
  for (const [title, types] of GROUPS) {
1111
  const shown = types.filter(t => alwaysShow.has(t) || prereqsMet(state, "player", BUILDINGS[t]));
1112
  if (!shown.length) continue;
 
384
  const d = sel.find(e => e.kind === "building" && e.owner === "player" && !e.constructing
385
  && storeCapOf(e.type) > 0 && !BUILDINGS[e.type].recipe && !BUILDINGS[e.type].isCommandCenter);
386
  return d ? Math.round((storeTotal(d) / (storeCapOf(d.type) || 1)) * 10) : "";
387
+ })()
388
+ // Rebuild a selected Combustion Generator's status when its fuel/power state flips.
389
+ + "|" + (() => {
390
+ const gen = sel.find(e => e.kind === "building" && e.type === "combustor" && !e.constructing);
391
+ return gen ? `${!!gen.paused}:${!!gen.powered}:${gen.fuel || ""}` : "";
392
  })();
393
 
394
  if (signature !== lastPanelSignature) {
 
979
  panelEl.appendChild(note);
980
  }
981
 
982
+ // Combustion Generator (Odyssey): cheap Power that burns gas/biomass over a small grid. Say how
983
+ // much it grants, whether it's fuelled right now, and let the player pause it to stop the fuel bill.
984
+ const gen = sel.find(e => e.kind === "building" && e.type === "combustor" && !e.constructing);
985
+ if (gen) {
986
+ const def = BUILDINGS.combustor;
987
+ const row = document.createElement("div");
988
+ const lit = !gen.paused && gen.powered;
989
+ row.className = "sel-note " + (gen.paused ? "" : lit ? "good" : "bad");
990
+ row.textContent = gen.paused ? `Paused — grants no Power (⚡${def.energyGrants} when running)`
991
+ : lit ? `Grants ⚡${def.energyGrants} Power · burning ${COM[gen.fuel]?.name || gen.fuel}`
992
+ : `Stalled — out of fuel (needs ${def.combust.fuels.map(f => COM[f]?.name || f).join(" or ")})`;
993
+ panelEl.appendChild(row);
994
+ const note = document.createElement("p");
995
+ note.className = "hint";
996
+ note.textContent = `Cheap Power on a tight grid — half a Reactor's reach. Burns ${def.combust.rate}/s of gas or biomass while running; keep the stockpile fed or pause it.`;
997
+ panelEl.appendChild(note);
998
+ panelEl.appendChild(makeButton(gen.paused ? "▶ Resume generator" : "⏸ Pause generator",
999
+ () => { gen.paused = !gen.paused; },
1000
+ { tip: gen.paused ? "Restart it (resumes burning fuel)" : "Stop it drawing fuel until resumed" }));
1001
+ }
1002
+
1003
  // Forward drop-off (Refinery / Foundry / Arsenal): a finite INTAKE buffer gatherers bank
1004
  // raw hauls into (engine/gather.js). When it fills, gatherers reroute elsewhere until a
1005
  // worker clears it to the Command Center — so surface how full it is and whether it's full.
 
1125
  // tier would bury the menu), mirroring how the Barracks hides units you can't yet
1126
  // field.
1127
  const GROUPS = [
1128
+ ["Economy", ["reactor", "combustor", "smelter", "datacenter", "assembler", "chipfab",
1129
  "machineworks", "antimatterforge", "aifoundry", "torpedoworks", "plasmarig"]],
1130
  ["Military", ["barracks", "foundry", "arsenal", "refinery", "turret", "habitat", "stardock"]],
1131
  ["Endgame", ["antimatter_gate"]],
1132
  ["Travel", ["spaceport"]],
1133
  ];
1134
  const alwaysShow = new Set(["barracks", "foundry", "arsenal", "refinery", "turret",
1135
+ "habitat", "reactor", "combustor", "smelter", "datacenter", "spaceport"]);
1136
  for (const [title, types] of GROUPS) {
1137
  const shown = types.filter(t => alwaysShow.has(t) || prereqsMet(state, "player", BUILDINGS[t]));
1138
  if (!shown.length) continue;
render.js CHANGED
@@ -524,17 +524,17 @@ function drawJumpStaging(ctx, state, view) {
524
  // player WHERE to drop one. Green (on-grid, no loss) → red (isolated, worst).
525
  const POWER_TIER_COLOR = { linked: "#4ade80", near: "#a3e635", far: "#fbbf24", isolated: "#f87171" };
526
 
527
- // Concentric efficiency zones around one Reactor at (rx, ry): a faint tinted disc per
528
  // finite band (painted largest-first so each inner band shows its own hue) with a dashed
529
- // boundary ring. Everything past the outermost ring is the 'isolated' tier. Shared by the
530
- // selected-Reactor overlay and the placement cue, so both read the same zones.
531
- function drawReactorBands(ctx, rx, ry) {
532
  const bands = POWER_TIERS.filter(t => Number.isFinite(t.max));
533
  ctx.save();
534
  for (let i = bands.length - 1; i >= 0; i--) { // largest radius first, so inner hues win
535
  const t = bands[i], col = POWER_TIER_COLOR[t.name];
536
  ctx.beginPath();
537
- ctx.arc(rx, ry, t.max, 0, Math.PI * 2);
538
  ctx.fillStyle = hexA(col, 0.05);
539
  ctx.fill();
540
  }
@@ -542,23 +542,26 @@ function drawReactorBands(ctx, rx, ry) {
542
  ctx.lineWidth = 1.5;
543
  for (const t of bands) {
544
  ctx.beginPath();
545
- ctx.arc(rx, ry, t.max, 0, Math.PI * 2);
546
  ctx.strokeStyle = hexA(POWER_TIER_COLOR[t.name], 0.55);
547
  ctx.stroke();
548
  }
549
  ctx.restore();
550
  }
551
 
552
- // The power-grid overlay around a SELECTED player Reactor: its efficiency zones, so a
553
- // player can see the on-grid / near / far bands before placing a factory or rig near it.
554
- // Drawn only for a selected Reactor (mirrors the Spaceport's jump-staging ring), so it
555
- // never clutters the field otherwise.
556
  function drawPowerGrid(ctx, state, view) {
557
  const selSet = new Set(state.selection);
 
558
  for (const b of state.buildings.values()) {
559
- if (b.type !== "reactor" || b.owner !== "player" || b.constructing || !selSet.has(b.id)) continue;
560
- if (view && !inView(view, b.x, b.y, POWER_TIERS[POWER_TIERS.length - 2].max + 8)) continue;
561
- drawReactorBands(ctx, b.x, b.y);
 
 
562
  }
563
  }
564
 
@@ -777,7 +780,7 @@ const RECIPE_OUT = Object.fromEntries(RECIPES.map(r => [r.id, r.out]));
777
  // recipe-running factory shows the commodity it OUTPUTS; a handful of non-recipe industrial
778
  // buildings get an explicit emoji for what they DO — the Reactor grants Power (⚡), the Datacenter
779
  // runs research (🔬), the Stardock is a capital-ship yard (🛰️), the Antimatter Gate is the wonder (🌀).
780
- const BUILDING_GLYPH = { reactor: "⚡", datacenter: "🔬", stardock: "🛰️", antimatter_gate: "🌀", plasmarig: "⛏️" };
781
  function factoryGlyph(type) {
782
  const def = BUILDINGS[type];
783
  if (def && def.recipe && RECIPE_OUT[def.recipe]) return COM[RECIPE_OUT[def.recipe]]?.ico || null;
@@ -1570,18 +1573,22 @@ function drawEffects(ctx) {
1570
  // and label ("On-grid · draw ×1.0" … "Isolated · draw ×2.3"). Mirrors how the Spaceport
1571
  // shows its jump radius — it turns "where do I put this factory?" into a visible choice.
1572
  function drawGhostPowerCue(ctx, state, ghost, def) {
1573
- let nearest = null, bestD = Infinity;
 
 
1574
  for (const b of state.buildings.values()) {
1575
- if (b.owner !== "player" || b.constructing || b.type !== "reactor") continue;
1576
- const d = Math.hypot(b.x - ghost.x, b.y - ghost.y);
1577
- if (d < bestD) { bestD = d; nearest = b; }
 
 
1578
  }
1579
 
1580
  const tier = powerEfficiency(state, "player", ghost.x, ghost.y);
1581
  const col = POWER_TIER_COLOR[tier.name];
1582
 
1583
  if (nearest) {
1584
- drawReactorBands(ctx, nearest.x, nearest.y);
1585
  ctx.save();
1586
  ctx.setLineDash([5, 5]);
1587
  ctx.strokeStyle = hexA(col, 0.8);
 
524
  // player WHERE to drop one. Green (on-grid, no loss) → red (isolated, worst).
525
  const POWER_TIER_COLOR = { linked: "#4ade80", near: "#a3e635", far: "#fbbf24", isolated: "#f87171" };
526
 
527
+ // Concentric efficiency zones around one power source at (rx, ry): a faint tinted disc per
528
  // finite band (painted largest-first so each inner band shows its own hue) with a dashed
529
+ // boundary ring. `scale` is the source's powerRange — a short-range Generator's zones shrink,
530
+ // so its rings sit tighter than a Reactor's. Shared by the selected-source overlay and the cue.
531
+ function drawReactorBands(ctx, rx, ry, scale = 1) {
532
  const bands = POWER_TIERS.filter(t => Number.isFinite(t.max));
533
  ctx.save();
534
  for (let i = bands.length - 1; i >= 0; i--) { // largest radius first, so inner hues win
535
  const t = bands[i], col = POWER_TIER_COLOR[t.name];
536
  ctx.beginPath();
537
+ ctx.arc(rx, ry, t.max * scale, 0, Math.PI * 2);
538
  ctx.fillStyle = hexA(col, 0.05);
539
  ctx.fill();
540
  }
 
542
  ctx.lineWidth = 1.5;
543
  for (const t of bands) {
544
  ctx.beginPath();
545
+ ctx.arc(rx, ry, t.max * scale, 0, Math.PI * 2);
546
  ctx.strokeStyle = hexA(POWER_TIER_COLOR[t.name], 0.55);
547
  ctx.stroke();
548
  }
549
  ctx.restore();
550
  }
551
 
552
+ // The power-grid overlay around a SELECTED player power source (Reactor or Combustion Generator):
553
+ // its efficiency zones, so a player can see the on-grid / near / far bands — scaled to the source's
554
+ // reach — before placing a factory or rig near it. Drawn only for a selected source (mirrors the
555
+ // Spaceport's jump-staging ring), so it never clutters the field otherwise.
556
  function drawPowerGrid(ctx, state, view) {
557
  const selSet = new Set(state.selection);
558
+ const outer = POWER_TIERS[POWER_TIERS.length - 2].max;
559
  for (const b of state.buildings.values()) {
560
+ const def = BUILDINGS[b.type];
561
+ if (!(def && def.energyGrants > 0) || b.owner !== "player" || b.constructing || !selSet.has(b.id)) continue;
562
+ const scale = def.powerRange || 1;
563
+ if (view && !inView(view, b.x, b.y, outer * scale + 8)) continue;
564
+ drawReactorBands(ctx, b.x, b.y, scale);
565
  }
566
  }
567
 
 
780
  // recipe-running factory shows the commodity it OUTPUTS; a handful of non-recipe industrial
781
  // buildings get an explicit emoji for what they DO — the Reactor grants Power (⚡), the Datacenter
782
  // runs research (🔬), the Stardock is a capital-ship yard (🛰️), the Antimatter Gate is the wonder (🌀).
783
+ const BUILDING_GLYPH = { reactor: "⚡", combustor: "🔥", datacenter: "🔬", stardock: "🛰️", antimatter_gate: "🌀", plasmarig: "⛏️" };
784
  function factoryGlyph(type) {
785
  const def = BUILDINGS[type];
786
  if (def && def.recipe && RECIPE_OUT[def.recipe]) return COM[RECIPE_OUT[def.recipe]]?.ico || null;
 
1573
  // and label ("On-grid · draw ×1.0" … "Isolated · draw ×2.3"). Mirrors how the Spaceport
1574
  // shows its jump radius — it turns "where do I put this factory?" into a visible choice.
1575
  function drawGhostPowerCue(ctx, state, ghost, def) {
1576
+ // The source that DETERMINES the ghost's tier: the nearest by RANGE-SCALED distance, so a
1577
+ // close short-range Generator can out-rank a far Reactor exactly as the efficiency math does.
1578
+ let nearest = null, bestD = Infinity, nearestScale = 1;
1579
  for (const b of state.buildings.values()) {
1580
+ const bd = BUILDINGS[b.type];
1581
+ if (b.owner !== "player" || b.constructing || !(bd && bd.energyGrants > 0)) continue;
1582
+ const scale = bd.powerRange || 1;
1583
+ const d = Math.hypot(b.x - ghost.x, b.y - ghost.y) / scale;
1584
+ if (d < bestD) { bestD = d; nearest = b; nearestScale = scale; }
1585
  }
1586
 
1587
  const tier = powerEfficiency(state, "player", ghost.x, ghost.y);
1588
  const col = POWER_TIER_COLOR[tier.name];
1589
 
1590
  if (nearest) {
1591
+ drawReactorBands(ctx, nearest.x, nearest.y, nearestScale);
1592
  ctx.save();
1593
  ctx.setLineDash([5, 5]);
1594
  ctx.strokeStyle = hexA(col, 0.8);
test/combustor.test.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createGameState, makeBuilding } from "../engine/state.js";
4
+ import { tick } from "../engine/sim.js";
5
+ import { serializeGame, deserializeGame } from "../engine/persist.js";
6
+ import { powerCap, powerEfficiency, updateCombustors } from "../engine/industry.js";
7
+ import { BUILDINGS } from "../engine/entities.js";
8
+
9
+ // A tiny stub, like industry.test.js: the power helpers read only buildings + resources.
10
+ function stub(buildings = [], resources = {}) {
11
+ return {
12
+ buildings: new Map(buildings.map((b, i) => {
13
+ const id = b.id || `b${i}`;
14
+ return [id, { id, owner: "player", constructing: false, ...b }];
15
+ })),
16
+ players: { player: { resources } },
17
+ };
18
+ }
19
+ const combustor = (o = {}) => ({ type: "combustor", ...o });
20
+ const reactor = (o = {}) => ({ type: "reactor", ...o });
21
+
22
+ test("a Combustion Generator grants Power only while it's fuelled", () => {
23
+ const s = stub([combustor()], { gas: 100 });
24
+ assert.equal(powerCap(s, "player"), 0, "before a tick it hasn't burned fuel → grants nothing");
25
+ updateCombustors(s, 0.1);
26
+ assert.equal(powerCap(s, "player"), BUILDINGS.combustor.energyGrants, "fuelled → grants its Power");
27
+ const gen = [...s.buildings.values()][0];
28
+ assert.ok(gen.powered && s.players.player.resources.gas < 100, "…having burned some gas to do it");
29
+ });
30
+
31
+ test("out of fuel (or paused), a Generator grants no Power", () => {
32
+ const dry = stub([combustor()], {}); // no gas, no biomass
33
+ updateCombustors(dry, 0.1);
34
+ assert.equal(powerCap(dry, "player"), 0, "no fuel → dead");
35
+
36
+ const paused = stub([combustor({ paused: true })], { biomass: 100 });
37
+ updateCombustors(paused, 0.1);
38
+ assert.equal(powerCap(paused, "player"), 0, "paused → dead, and no fuel burned");
39
+ assert.equal(paused.players.player.resources.biomass, 100, "…the stockpile is untouched");
40
+ });
41
+
42
+ test("it burns gas OR biomass — whichever the stockpile has more of", () => {
43
+ const s = stub([combustor()], { biomass: 50 }); // only biomass on hand
44
+ updateCombustors(s, 0.1);
45
+ const gen = [...s.buildings.values()][0];
46
+ assert.equal(gen.fuel, "biomass", "falls back to biomass when there's no gas");
47
+ assert.ok(s.players.player.resources.biomass < 50, "…and consumes it");
48
+ });
49
+
50
+ test("a Generator's grid is SMALLER than a Reactor's (its powerRange shrinks the tiers)", () => {
51
+ // 250px out: on a Reactor's grid that's the 'near' tier; on a Generator's tighter grid it's already 'isolated'.
52
+ const nearReactor = stub([reactor({ x: 0, y: 0 })]);
53
+ const nearGen = stub([combustor({ x: 0, y: 0, powered: true })]); // pre-mark fuelled so it counts as a source
54
+ const rTier = powerEfficiency(nearReactor, "player", 250, 0).name;
55
+ const gTier = powerEfficiency(nearGen, "player", 250, 0).name;
56
+ assert.equal(rTier, "near", "a Reactor still reaches 250px at the 'near' tier");
57
+ assert.ok(BUILDINGS.combustor.powerRange < 1, "the Generator has a shorter reach");
58
+ assert.notEqual(gTier, "linked", "the same 250px spot is off the Generator's tight on-grid zone");
59
+ assert.ok(["far", "isolated"].includes(gTier), `…it's a worse tier on the smaller grid (${gTier})`);
60
+ });
61
+
62
+ test("an unfuelled Generator isn't a grid source at all (no false on-grid reading)", () => {
63
+ const s = stub([combustor({ x: 0, y: 0 })]); // not powered
64
+ assert.equal(powerEfficiency(s, "player", 30, 0).name, "linked",
65
+ "with no active source, a spot reads the neutral tier — not 'on-grid' off a dead Generator");
66
+ });
67
+
68
+ test("the Generator is Odyssey-only and its fuel state is deterministic + save-clean", () => {
69
+ assert.equal(BUILDINGS.combustor.odysseyOnly, true);
70
+ const run = () => {
71
+ const s = createGameState({ planetId: "ferros", endless: true });
72
+ const gen = makeBuilding("combustor", "player", 600, 500);
73
+ s.buildings.set(gen.id, gen);
74
+ s.players.player.resources.gas = 100;
75
+ for (let i = 0; i < 50; i++) tick(s, 0.1);
76
+ return s.players.player.resources.gas;
77
+ };
78
+ assert.equal(run(), run(), "same seed → identical fuel burn");
79
+
80
+ // `powered`/`fuel` are transient — they must not ride along in a save.
81
+ const s = createGameState({ planetId: "ferros", endless: true });
82
+ const gen = makeBuilding("combustor", "player", 600, 500);
83
+ s.buildings.set(gen.id, gen);
84
+ s.players.player.resources.gas = 100;
85
+ tick(s, 0.1);
86
+ const saved = serializeGame(s).buildings.find(b => b.type === "combustor");
87
+ assert.equal(saved.powered, undefined, "the transient power flag is stripped from the save");
88
+ assert.equal(saved.fuel, undefined, "…and so is the transient fuel tag");
89
+ assert.ok(deserializeGame(serializeGame(s)), "and the save round-trips");
90
+ });