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

Workers trainable on biomass as an alternative to ore

Browse files

A 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

Files changed (4) hide show
  1. engine/entities.js +5 -1
  2. engine/production.js +12 -5
  3. hud.js +9 -0
  4. test/production.test.js +18 -0
engine/entities.js CHANGED
@@ -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
engine/production.js CHANGED
@@ -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, def.cost)) return false;
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, def.cost);
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
- payCost(player.resources, negate(UNITS[job.unitType].cost));
 
 
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
 
hud.js CHANGED
@@ -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
test/production.test.js CHANGED
@@ -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");