Spaces:
Paused
Workers trainable on biomass as an alternative to ore
Browse filesA worker now carries an `altCost` ({biomass: 50}) alongside its ore price — an
either/or, so a biomass-rich but ore-poor claim can still grow its workforce.
- entities.js: worker.altCost.
- production.js: queueProduction(state, id, type, alt) charges the alternative price when
asked and tags the queued job (job.alt); cancelProduction refunds whichever commodity
was actually charged.
- hud.js: the Command Center shows a second "Produce Worker (50 biomass)" button beside
the ore one; the affordability signature tracks altCosts so it greys live.
Tests: production.test.js (biomass charged not ore, job tagged, cancel refunds biomass).
Full suite 597 green, tsc clean. AI path unchanged (it queues via the ore cost only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AEQUVetLk31Jj7niDQAsXL
- engine/entities.js +5 -1
- engine/production.js +12 -5
- hud.js +9 -0
- test/production.test.js +18 -0
|
@@ -400,7 +400,11 @@ export function committedDoctrine(state, owner) {
|
|
| 400 |
export const UNITS = {
|
| 401 |
worker: {
|
| 402 |
id: "worker", name: "Worker", hp: 40, radius: 6, speed: 60,
|
| 403 |
-
cost: { ore: 50 }, buildTime: 8, supplyCost: 1,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
role: "worker", gatherRate: 10, cargoCap: 10,
|
| 405 |
// Saturation: the first `minerSoftCap` workers on a deposit mine at full
|
| 406 |
// rate; each one past that pulls only `minerFalloff` of a full share (see
|
|
|
|
| 400 |
export const UNITS = {
|
| 401 |
worker: {
|
| 402 |
id: "worker", name: "Worker", hp: 40, radius: 6, speed: 60,
|
| 403 |
+
cost: { ore: 50 }, altCost: { biomass: 50 }, buildTime: 8, supplyCost: 1,
|
| 404 |
+
// `altCost` is an EITHER/OR price: a worker can be trained on ore (the default) or on
|
| 405 |
+
// biomass instead (engine/production.js queueProduction's `alt` flag) — so a crystal/ore-poor,
|
| 406 |
+
// biomass-rich claim can still grow its workforce. The queued job remembers which it paid, so a
|
| 407 |
+
// cancel refunds the right commodity.
|
| 408 |
role: "worker", gatherRate: 10, cargoCap: 10,
|
| 409 |
// Saturation: the first `minerSoftCap` workers on a deposit mine at full
|
| 410 |
// rate; each one past that pulls only `minerFalloff` of a full share (see
|
|
@@ -94,12 +94,17 @@ export function updateProductionQueue(state, building, dt) {
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
| 97 |
-
export function queueProduction(state, buildingId, unitType) {
|
| 98 |
const building = state.buildings.get(buildingId);
|
| 99 |
if (!building || building.constructing) return false;
|
| 100 |
const def = UNITS[unitType];
|
| 101 |
const producable = def && BUILDINGS[building.type].produces?.includes(unitType);
|
| 102 |
if (!producable) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
// An Odyssey-only unit (e.g. the Colony Ship) can never be built in a skirmish,
|
| 104 |
// regardless of menu wiring — hard-guarantees it stays out of the byte-identical
|
| 105 |
// skirmish path. A no-op for existing skirmish (no odysseyOnly unit is reachable).
|
|
@@ -112,7 +117,7 @@ export function queueProduction(state, buildingId, unitType) {
|
|
| 112 |
return false;
|
| 113 |
}
|
| 114 |
const player = state.players[building.owner];
|
| 115 |
-
if (!canAfford(player.resources,
|
| 116 |
// Supply check sits AFTER canAfford (so being broke stays the silent
|
| 117 |
// today-UX, and the supply beep only fires when supply is the real
|
| 118 |
// blocker) and BEFORE payCost (reject before charging — nothing to
|
|
@@ -123,8 +128,8 @@ export function queueProduction(state, buildingId, unitType) {
|
|
| 123 |
x: building.x, y: building.y, owner: building.owner });
|
| 124 |
return false;
|
| 125 |
}
|
| 126 |
-
payCost(player.resources,
|
| 127 |
-
building.queue.push({ unitType, progress: 0 });
|
| 128 |
return true;
|
| 129 |
}
|
| 130 |
|
|
@@ -141,7 +146,9 @@ export function cancelProduction(state, buildingId, queueIndex) {
|
|
| 141 |
if (!job) return false;
|
| 142 |
building.queue.splice(queueIndex, 1);
|
| 143 |
const player = state.players[building.owner];
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
return true;
|
| 146 |
}
|
| 147 |
|
|
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
| 97 |
+
export function queueProduction(state, buildingId, unitType, alt = false) {
|
| 98 |
const building = state.buildings.get(buildingId);
|
| 99 |
if (!building || building.constructing) return false;
|
| 100 |
const def = UNITS[unitType];
|
| 101 |
const producable = def && BUILDINGS[building.type].produces?.includes(unitType);
|
| 102 |
if (!producable) return false;
|
| 103 |
+
// A unit with an `altCost` (the Worker) can be trained on its alternative price — the queued job
|
| 104 |
+
// remembers which it paid so a cancel refunds the right commodity. Falls back to the base cost if
|
| 105 |
+
// this unit has no alternative.
|
| 106 |
+
const useAlt = alt && !!def.altCost;
|
| 107 |
+
const cost = useAlt ? def.altCost : def.cost;
|
| 108 |
// An Odyssey-only unit (e.g. the Colony Ship) can never be built in a skirmish,
|
| 109 |
// regardless of menu wiring — hard-guarantees it stays out of the byte-identical
|
| 110 |
// skirmish path. A no-op for existing skirmish (no odysseyOnly unit is reachable).
|
|
|
|
| 117 |
return false;
|
| 118 |
}
|
| 119 |
const player = state.players[building.owner];
|
| 120 |
+
if (!canAfford(player.resources, cost)) return false;
|
| 121 |
// Supply check sits AFTER canAfford (so being broke stays the silent
|
| 122 |
// today-UX, and the supply beep only fires when supply is the real
|
| 123 |
// blocker) and BEFORE payCost (reject before charging — nothing to
|
|
|
|
| 128 |
x: building.x, y: building.y, owner: building.owner });
|
| 129 |
return false;
|
| 130 |
}
|
| 131 |
+
payCost(player.resources, cost);
|
| 132 |
+
building.queue.push({ unitType, progress: 0, ...(useAlt ? { alt: true } : {}) });
|
| 133 |
return true;
|
| 134 |
}
|
| 135 |
|
|
|
|
| 146 |
if (!job) return false;
|
| 147 |
building.queue.splice(queueIndex, 1);
|
| 148 |
const player = state.players[building.owner];
|
| 149 |
+
const def = UNITS[job.unitType];
|
| 150 |
+
const refund = (job.alt && def.altCost) ? def.altCost : def.cost; // refund the commodity actually charged
|
| 151 |
+
payCost(player.resources, negate(refund));
|
| 152 |
return true;
|
| 153 |
}
|
| 154 |
|
|
@@ -281,6 +281,7 @@ function availabilitySignature() {
|
|
| 281 |
const res = state.players.player.resources;
|
| 282 |
const costs = [
|
| 283 |
...Object.values(UNITS).map(u => u.cost),
|
|
|
|
| 284 |
...Object.values(BUILDINGS).map(b => b.cost),
|
| 285 |
...Object.values(UPGRADES).map(u => u.cost),
|
| 286 |
];
|
|
@@ -764,6 +765,14 @@ function rebuildSelectionPanel(sel) {
|
|
| 764 |
panelEl.appendChild(prodButton(`Produce ${def.name} (${costText(def.cost)})`,
|
| 765 |
() => queueProduction(state, cc.id, t),
|
| 766 |
{ cost: def.cost, tip: unitTip(def), locked, lockTip: locked ? lockTipFor(def) : null, icon: { kind: "unit", type: t } }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
}
|
| 768 |
if (cc.queue.length) renderQueueRows(cc);
|
| 769 |
if (game.galaxy) renderCapital(state, cc); // Odyssey: fortify this CC into the anchored Capital
|
|
|
|
| 281 |
const res = state.players.player.resources;
|
| 282 |
const costs = [
|
| 283 |
...Object.values(UNITS).map(u => u.cost),
|
| 284 |
+
...Object.values(UNITS).filter(u => u.altCost).map(u => u.altCost), // e.g. the Worker's biomass price
|
| 285 |
...Object.values(BUILDINGS).map(b => b.cost),
|
| 286 |
...Object.values(UPGRADES).map(u => u.cost),
|
| 287 |
];
|
|
|
|
| 765 |
panelEl.appendChild(prodButton(`Produce ${def.name} (${costText(def.cost)})`,
|
| 766 |
() => queueProduction(state, cc.id, t),
|
| 767 |
{ cost: def.cost, tip: unitTip(def), locked, lockTip: locked ? lockTipFor(def) : null, icon: { kind: "unit", type: t } }));
|
| 768 |
+
// A unit with an alternative price (the Worker, buildable on biomass instead of ore) gets a
|
| 769 |
+
// second button paying that cost — so a biomass-rich, ore-poor claim can still grow its labour.
|
| 770 |
+
if (def.altCost) {
|
| 771 |
+
panelEl.appendChild(prodButton(`Produce ${def.name} (${costText(def.altCost)})`,
|
| 772 |
+
() => queueProduction(state, cc.id, t, true),
|
| 773 |
+
{ cost: def.altCost, tip: `${def.name} paid in ${costText(def.altCost)} instead of ore`, locked,
|
| 774 |
+
lockTip: locked ? lockTipFor(def) : null, icon: { kind: "unit", type: t } }));
|
| 775 |
+
}
|
| 776 |
}
|
| 777 |
if (cc.queue.length) renderQueueRows(cc);
|
| 778 |
if (game.galaxy) renderCapital(state, cc); // Odyssey: fortify this CC into the anchored Capital
|
|
@@ -17,6 +17,24 @@ function withFoundry(state, owner = "player") {
|
|
| 17 |
return f;
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
test("queueProduction pays the cost and enqueues the job", () => {
|
| 21 |
const state = createGameState({ planetId: "ferros" });
|
| 22 |
const cc = commandCenterOf(state, "player");
|
|
|
|
| 17 |
return f;
|
| 18 |
}
|
| 19 |
|
| 20 |
+
test("a Worker can be trained on biomass instead of ore, and a cancel refunds the right commodity", () => {
|
| 21 |
+
const state = createGameState({ planetId: "ferros" });
|
| 22 |
+
const cc = commandCenterOf(state, "player");
|
| 23 |
+
const res = state.players.player.resources;
|
| 24 |
+
res.ore = 0; res.biomass = 200; // ore-poor, biomass-rich claim
|
| 25 |
+
assert.equal(queueProduction(state, cc.id, "worker"), false, "no ore → the ore-priced Worker is unaffordable");
|
| 26 |
+
|
| 27 |
+
const biomassBefore = res.biomass;
|
| 28 |
+
assert.equal(queueProduction(state, cc.id, "worker", true), true, "…but the biomass-priced Worker queues");
|
| 29 |
+
assert.equal(res.biomass, biomassBefore - UNITS.worker.altCost.biomass, "biomass was charged, not ore");
|
| 30 |
+
assert.equal(res.ore, 0, "ore untouched");
|
| 31 |
+
assert.equal(cc.queue[0].alt, true, "the job remembers it paid the alternative price");
|
| 32 |
+
|
| 33 |
+
cancelProduction(state, cc.id, 0);
|
| 34 |
+
assert.equal(res.biomass, biomassBefore, "cancelling refunds biomass (the commodity actually charged)");
|
| 35 |
+
assert.equal(res.ore, 0, "…and not ore");
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
test("queueProduction pays the cost and enqueues the job", () => {
|
| 39 |
const state = createGameState({ planetId: "ferros" });
|
| 40 |
const cc = commandCenterOf(state, "player");
|