Spaces:
Paused
Combustion Generator: cheap gas/biomass power on a tight grid
Browse filesA 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
- engine/entities.js +13 -0
- engine/industry.js +43 -12
- engine/persist.js +4 -4
- engine/sim.js +4 -1
- engine/types.js +2 -0
- hud.js +28 -2
- render.js +26 -19
- test/combustor.test.js +90 -0
|
@@ -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: {
|
|
@@ -54,31 +54,58 @@ export const POWER_TIERS = [
|
|
| 54 |
{ name: "isolated", max: Infinity, mult: 2.3, label: "Isolated" },
|
| 55 |
];
|
| 56 |
|
| 57 |
-
//
|
| 58 |
-
//
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
let best = Infinity;
|
| 62 |
for (const b of state.buildings.values()) {
|
| 63 |
if (b.owner !== owner || b.constructing) continue;
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 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
|
| 72 |
-
//
|
| 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 =
|
| 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 ==
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
//
|
| 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],
|
|
@@ -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).
|
|
@@ -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 |
/**
|
|
@@ -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;
|
|
@@ -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
|
| 528 |
// finite band (painted largest-first so each inner band shows its own hue) with a dashed
|
| 529 |
-
// boundary ring.
|
| 530 |
-
//
|
| 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
|
| 553 |
-
// player can see the on-grid / near / far bands
|
| 554 |
-
// Drawn only for a selected
|
| 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 |
-
|
| 560 |
-
if (
|
| 561 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 1574 |
for (const b of state.buildings.values()) {
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
|
|
|
|
|
|
| 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);
|
|
@@ -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 |
+
});
|