Claude Claude Fable 5 commited on
Commit
d228ca7
Β·
unverified Β·
1 Parent(s): 1ae322f

Asymmetric world modifiers: two starts that differ

Browse files

World modifiers could only tilt both sides equally. A world may now carry an
`asym: { player, ai }` block that overrides a modifier for just ONE side, so the
two corners play as a real asymmetric matchup.

- map.js: sideMod(state, owner, key) resolves the owner's asym override, then the
shared modifier, then the default β€” so symmetric worlds and map-less test
stubs behave exactly as before. Movement (speed), fog (sight), combat (aggro
sight), production (build time), and gather (a new per-side gatherMult economy
edge) all read it with owner context.
- Two asymmetric worlds, telegraphed on the map card:
- Oort "Contested frontier": player banks 20% more per haul (rich claim); the
AI builds 18% faster (war factory). Out-mine vs out-build.
- Nimbus "Storm front": player sees almost normally (0.95) where the AI stays
half-blind (0.75), but the AI's units are 12% faster. Out-scout vs out-tempo.

Safe for the resolves-to-a-winner guarantee by construction: an AI-side edge
only helps it finish, and a player-side edge doesn't help a passive player β€” so
every world still resolves (verified). Symmetric worlds (e.g. Glacius) still
tilt both sides identically. Determinism holds.

New test asserts per-side application on the asym worlds and equal application on
the symmetric ones. 234 pass. README updated.

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

README.md CHANGED
@@ -34,6 +34,8 @@ A Refinery (built by a Worker, like the Barracks) researches army-wide upgrades
34
 
35
  Each world gives the AI a different temperament β€” Rusher (small economy, commits early), Economist (out-scales before attacking, expands and turtles behind turrets), or Balanced β€” and it plays the whole toolkit: it attacks in repeated waves, not just once (a fresh batch of units becomes the next wave once it's big enough or the timeout comes back around); it scouts, building the direct counter to whatever combat type the player fields most every third unit; and it expands when its home ore runs thin, fortifies the approach lane with turrets, runs multiple Barracks, and raises Habitats to stay under supply. Six of the nine worlds also carry a single rule modifier that applies to both sides β€” Glacius's ice slows everyone, Nimbus's storms shorten sight, Pyralis's open dunes lengthen it, Helix packs an extra crystal field, Oort's deposits run rich, and Forge's industry speeds construction β€” so where you fight changes how the fight plays, not just what you can build. Six of the nine worlds also carry **terrain**: rough ground you cross slower (Glacius's ice fields, Forge's industrial sprawl, and Oort's rugged flanks funnel armies through the open lanes) and high ground that sees farther and hits harder β€” Pyralis's central mesa, Helix's crystal ridge, and Nimbus's vantages (doubly worth taking on a storm-shortened world, since high ground extends sight) are real objectives to seize. Terrain is never impassable β€” it slows and shapes, it doesn't wall β€” and it's mirrored, so both sides face the same ground. You can't build on rough ground, so base and turret layout have to work around it. The three original worlds (Ferros, Korrath, Vesper) stay clean open plains.
36
 
 
 
37
  A minimap (bottom-left) shows the whole map at a glance β€” fog, resource deposits, and every unit/building you can currently see β€” and doubles as a camera shortcut: click anywhere on it to jump the main view there. An "Under Attack" banner (with its own alarm sound and a pulsing ping on both the main view and the minimap) fires when the AI hits something of yours, so a raid on an unwatched flank doesn't go unnoticed. Combat itself is legible at a glance too: a tracer flashes from attacker to target on every hit (colored by weapon type), and a brief ring marks a kill.
38
 
39
  Placing a building shows a live green/red footprint preview under the cursor before you click β€” invalid spots (off the map, overlapping another building, too close to a resource node) are rejected with no cost and no need to reselect. The selection panel shows what's queued at a selected Command Center or Barracks (with live progress on the in-progress job) and lets you cancel any queued unit for a full refund; selecting a group of units collapses the panel to one row per type ("12Γ— Skiff β€” 84% hp") instead of one per unit.
 
34
 
35
  Each world gives the AI a different temperament β€” Rusher (small economy, commits early), Economist (out-scales before attacking, expands and turtles behind turrets), or Balanced β€” and it plays the whole toolkit: it attacks in repeated waves, not just once (a fresh batch of units becomes the next wave once it's big enough or the timeout comes back around); it scouts, building the direct counter to whatever combat type the player fields most every third unit; and it expands when its home ore runs thin, fortifies the approach lane with turrets, runs multiple Barracks, and raises Habitats to stay under supply. Six of the nine worlds also carry a single rule modifier that applies to both sides β€” Glacius's ice slows everyone, Nimbus's storms shorten sight, Pyralis's open dunes lengthen it, Helix packs an extra crystal field, Oort's deposits run rich, and Forge's industry speeds construction β€” so where you fight changes how the fight plays, not just what you can build. Six of the nine worlds also carry **terrain**: rough ground you cross slower (Glacius's ice fields, Forge's industrial sprawl, and Oort's rugged flanks funnel armies through the open lanes) and high ground that sees farther and hits harder β€” Pyralis's central mesa, Helix's crystal ridge, and Nimbus's vantages (doubly worth taking on a storm-shortened world, since high ground extends sight) are real objectives to seize. Terrain is never impassable β€” it slows and shapes, it doesn't wall β€” and it's mirrored, so both sides face the same ground. You can't build on rough ground, so base and turret layout have to work around it. The three original worlds (Ferros, Korrath, Vesper) stay clean open plains.
36
 
37
+ Two worlds are **asymmetric matchups** β€” the modifier hits the two sides *differently*, so which corner you start in defines your plan. On Oort ("Contested frontier") your claim struck a rich vein (your hauls bank 20% more) while the enemy's forward base is a war factory (18% faster construction) β€” you out-mine, they out-build. On Nimbus ("Storm front") the storm has half-cleared your side (you see almost normally where the enemy is half-blind) but the enemy surges fast out of the murk (12% quicker units) β€” you out-scout, they out-tempo. The card flags these, so you know the matchup before you commit.
38
+
39
  A minimap (bottom-left) shows the whole map at a glance β€” fog, resource deposits, and every unit/building you can currently see β€” and doubles as a camera shortcut: click anywhere on it to jump the main view there. An "Under Attack" banner (with its own alarm sound and a pulsing ping on both the main view and the minimap) fires when the AI hits something of yours, so a raid on an unwatched flank doesn't go unnoticed. Combat itself is legible at a glance too: a tracer flashes from attacker to target on every hit (colored by weapon type), and a brief ring marks a kill.
40
 
41
  Placing a building shows a live green/red footprint preview under the cursor before you click β€” invalid spots (off the map, overlapping another building, too close to a resource node) are rejected with no cost and no need to reselect. The selection panel shows what's queued at a selected Command Center or Barracks (with live progress on the in-progress job) and lets you cancel any queued unit for a full refund; selecting a group of units collapses the panel to one row per type ("12Γ— Skiff β€” 84% hp") instead of one per unit.
engine/combat.js CHANGED
@@ -19,7 +19,7 @@ import { stepToward } from "./movement.js";
19
  import { UNITS, BUILDINGS, UPGRADES } from "./entities.js";
20
  import { getEntity, removeEntity } from "./state.js";
21
  import { queryNeighbors } from "./grid.js";
22
- import { sampleTerrain } from "./map.js";
23
 
24
  export function updateCombat(state, unit, dt) {
25
  const def = UNITS[unit.type];
@@ -161,7 +161,7 @@ function isAlive(state, id) {
161
  function stillEngageable(state, unit, def, id) {
162
  const e = getEntity(state, id);
163
  if (!e || e.hp <= 0) return false;
164
- const aggro = def.aggroRange * (state.map?.modifiers?.sightMult ?? 1);
165
  return Math.hypot(e.x - unit.x, e.y - unit.y) <= aggro;
166
  }
167
 
@@ -218,7 +218,7 @@ function acquireTarget(state, unit, def) {
218
  // The same sight modifier that scales fog reveal also scales how far a unit
219
  // (or turret) reaches out to acquire β€” so a storm-shortened world bites both
220
  // sides' aggro symmetrically. Optional-chained for map-less test states.
221
- const aggro = def.aggroRange * (state.map?.modifiers?.sightMult ?? 1);
222
  // Units through the broad-phase grid (there can be hundreds); buildings stay a
223
  // straight scan since there are only ever a handful. Full-scan fallback when
224
  // no grid is present (direct combat tests).
 
19
  import { UNITS, BUILDINGS, UPGRADES } from "./entities.js";
20
  import { getEntity, removeEntity } from "./state.js";
21
  import { queryNeighbors } from "./grid.js";
22
+ import { sampleTerrain, sideMod } from "./map.js";
23
 
24
  export function updateCombat(state, unit, dt) {
25
  const def = UNITS[unit.type];
 
161
  function stillEngageable(state, unit, def, id) {
162
  const e = getEntity(state, id);
163
  if (!e || e.hp <= 0) return false;
164
+ const aggro = def.aggroRange * sideMod(state, unit.owner, "sightMult");
165
  return Math.hypot(e.x - unit.x, e.y - unit.y) <= aggro;
166
  }
167
 
 
218
  // The same sight modifier that scales fog reveal also scales how far a unit
219
  // (or turret) reaches out to acquire β€” so a storm-shortened world bites both
220
  // sides' aggro symmetrically. Optional-chained for map-less test states.
221
+ const aggro = def.aggroRange * sideMod(state, unit.owner, "sightMult");
222
  // Units through the broad-phase grid (there can be hundreds); buildings stay a
223
  // straight scan since there are only ever a handful. Full-scan fallback when
224
  // no grid is present (direct combat tests).
engine/fog.js CHANGED
@@ -17,7 +17,7 @@
17
  "use strict";
18
 
19
  import { UNITS, BUILDINGS } from "./entities.js";
20
- import { sampleTerrain } from "./map.js";
21
 
22
  export const FOG_CELL_SIZE = 40;
23
 
@@ -75,7 +75,7 @@ export function updateFog(state, fog, owner) {
75
  fog.visible.fill(0);
76
  // A world's sight modifier scales every reveal radius (see PLANET_MODIFIERS).
77
  // Optional-chained so the fog tests' map-less stubs read the default 1.
78
- const sightMult = state.map?.modifiers?.sightMult ?? 1;
79
  // A source standing on high ground sees farther (terrain sightMult); OPEN and
80
  // the map-less test stubs read 1. One sample per source per tick.
81
  const terr = state.map?.terrain;
 
17
  "use strict";
18
 
19
  import { UNITS, BUILDINGS } from "./entities.js";
20
+ import { sampleTerrain, sideMod } from "./map.js";
21
 
22
  export const FOG_CELL_SIZE = 40;
23
 
 
75
  fog.visible.fill(0);
76
  // A world's sight modifier scales every reveal radius (see PLANET_MODIFIERS).
77
  // Optional-chained so the fog tests' map-less stubs read the default 1.
78
+ const sightMult = sideMod(state, owner, "sightMult"); // per-side on an asymmetric world (updateFog is already called per owner)
79
  // A source standing on high ground sees farther (terrain sightMult); OPEN and
80
  // the map-less test stubs read 1. One sample per source per tick.
81
  const terr = state.map?.terrain;
engine/gather.js CHANGED
@@ -8,6 +8,7 @@
8
 
9
  import { stepToward } from "./movement.js";
10
  import { UNITS } from "./entities.js";
 
11
 
12
  const ORBIT_RADIUS = 16; // workers ring the node instead of stacking on its exact center
13
  const ARRIVE_REACH = 4;
@@ -75,7 +76,10 @@ export function updateGather(state, unit, dt) {
75
  const dist = Math.hypot(drop.x - unit.x, drop.y - unit.y);
76
  if (dist <= DROP_REACH) {
77
  const player = state.players[unit.owner];
78
- player.resources[unit.cargo.com] = (player.resources[unit.cargo.com] || 0) + unit.cargo.qty;
 
 
 
79
  unit.cargo.qty = 0;
80
  order.phase = node.amount > 0 ? "toNode" : null;
81
  if (!order.phase) unit.order = null;
 
8
 
9
  import { stepToward } from "./movement.js";
10
  import { UNITS } from "./entities.js";
11
+ import { sideMod } from "./map.js";
12
 
13
  const ORBIT_RADIUS = 16; // workers ring the node instead of stacking on its exact center
14
  const ARRIVE_REACH = 4;
 
76
  const dist = Math.hypot(drop.x - unit.x, drop.y - unit.y);
77
  if (dist <= DROP_REACH) {
78
  const player = state.players[unit.owner];
79
+ // Per-side economy modifier for an asymmetric world (default 1 elsewhere):
80
+ // a richer claim banks more per haul.
81
+ const banked = unit.cargo.qty * sideMod(state, unit.owner, "gatherMult", 1);
82
+ player.resources[unit.cargo.com] = (player.resources[unit.cargo.com] || 0) + banked;
83
  unit.cargo.qty = 0;
84
  order.phase = node.amount > 0 ? "toNode" : null;
85
  if (!order.phase) unit.order = null;
engine/map.js CHANGED
@@ -80,6 +80,20 @@ function generateTerrain(width, height, specs) {
80
  return { cols, rows, cell: TERRAIN_CELL_SIZE, type };
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  // The TERRAIN entry at a world point. Returns OPEN for a missing grid or an
84
  // out-of-bounds point, so every consumer degrades to "no terrain effect"
85
  // safely (map-less test stubs, off-map coords).
@@ -224,11 +238,15 @@ export const PLANET_MODIFIERS = {
224
  terrain: [[0.5, 0.13, 0.34, 0.2, 1, false], [0.5, 0.87, 0.34, 0.2, 1, false]],
225
  },
226
  nimbus: {
227
- sightMult: 0.75, label: "Storm bands: sight 25% shorter; two high vantages rise above the murk",
228
  // On a short-sight world, high ground (which extends sight) is doubly worth
229
  // taking β€” a way to see over the storm. Two vantages, north and south of
230
  // the midline, kept off the centre so neither base overlooks the field.
231
  terrain: [[0.5, 0.28, 0.12, 0.14, 2, false], [0.5, 0.72, 0.12, 0.14, 2, false]],
 
 
 
 
232
  },
233
  pyralis: {
234
  sightMult: 1.15, label: "Open dunes: long sightlines, and a central mesa worth holding",
@@ -243,10 +261,14 @@ export const PLANET_MODIFIERS = {
243
  terrain: [[0.5, 0.5, 0.1, 0.38, 2, false]],
244
  },
245
  oort: {
246
- nodeAmountMult: 1.3, label: "Rich frontier: deposits hold 30% more, but rugged ground breaks up the flanks",
247
  // Rugged rough ground on the flanks funnels the fight through the open
248
  // centre β€” the price of the world's rich but broken frontier.
249
  terrain: [[0.4, 0.28, 0.12, 0.18, 1, true], [0.4, 0.72, 0.12, 0.18, 1, true]],
 
 
 
 
250
  },
251
  forge: {
252
  buildTimeMult: 0.85, label: "Factory world: 15% faster construction; rough industrial sprawl midfield",
 
80
  return { cols, rows, cell: TERRAIN_CELL_SIZE, type };
81
  }
82
 
83
+ // A world modifier as seen by ONE side. Most worlds tilt both sides equally
84
+ // (a plain `modifiers[key]`), but a world may carry an `asym: { player, ai }`
85
+ // block that overrides a key for just one owner β€” an asymmetric matchup where
86
+ // the two starts differ. Lookup order: the owner's asym override, then the
87
+ // shared modifier, then the default. Owner-less / map-less test stubs read the
88
+ // shared value (or the default), so existing symmetric behaviour is unchanged.
89
+ export function sideMod(state, owner, key, dflt = 1) {
90
+ const m = state && state.map && state.map.modifiers;
91
+ if (!m) return dflt;
92
+ const a = m.asym && m.asym[owner];
93
+ if (a && a[key] != null) return a[key];
94
+ return m[key] ?? dflt;
95
+ }
96
+
97
  // The TERRAIN entry at a world point. Returns OPEN for a missing grid or an
98
  // out-of-bounds point, so every consumer degrades to "no terrain effect"
99
  // safely (map-less test stubs, off-map coords).
 
238
  terrain: [[0.5, 0.13, 0.34, 0.2, 1, false], [0.5, 0.87, 0.34, 0.2, 1, false]],
239
  },
240
  nimbus: {
241
+ sightMult: 0.75, label: "Storm front (asymmetric): your skies are clearer; the enemy surges out of the murk",
242
  // On a short-sight world, high ground (which extends sight) is doubly worth
243
  // taking β€” a way to see over the storm. Two vantages, north and south of
244
  // the midline, kept off the centre so neither base overlooks the field.
245
  terrain: [[0.5, 0.28, 0.12, 0.14, 2, false], [0.5, 0.72, 0.12, 0.14, 2, false]],
246
+ // Asymmetric matchup: the storm has half-cleared YOUR side (you see almost
247
+ // normally, 0.95 vs the enemy's 0.75), but the enemy strikes fast out of it
248
+ // (units 12% quicker). You out-scout; they out-tempo.
249
+ asym: { player: { sightMult: 0.95 }, ai: { speedMult: 1.12 } },
250
  },
251
  pyralis: {
252
  sightMult: 1.15, label: "Open dunes: long sightlines, and a central mesa worth holding",
 
261
  terrain: [[0.5, 0.5, 0.1, 0.38, 2, false]],
262
  },
263
  oort: {
264
+ nodeAmountMult: 1.3, label: "Contested frontier (asymmetric): your claim is richer; the enemy's foundry runs hotter",
265
  // Rugged rough ground on the flanks funnels the fight through the open
266
  // centre β€” the price of the world's rich but broken frontier.
267
  terrain: [[0.4, 0.28, 0.12, 0.18, 1, true], [0.4, 0.72, 0.12, 0.18, 1, true]],
268
+ // Asymmetric matchup: YOUR claim struck a rich vein (every haul banks 20%
269
+ // more), while the enemy's forward base is a war factory (18% faster
270
+ // construction and production). You out-mine; they out-build.
271
+ asym: { player: { gatherMult: 1.2 }, ai: { buildTimeMult: 0.82 } },
272
  },
273
  forge: {
274
  buildTimeMult: 0.85, label: "Factory world: 15% faster construction; rough industrial sprawl midfield",
engine/movement.js CHANGED
@@ -18,7 +18,7 @@
18
 
19
  import { UNITS } from "./entities.js";
20
  import { queryNeighbors } from "./grid.js";
21
- import { sampleTerrain } from "./map.js";
22
 
23
  const AVOID_RANGE = 30; // how far out a unit senses neighbors to steer around, beyond their combined radii
24
  const AVOID_WEIGHT = 1.6; // how strongly a sensed neighbor bends the seek direction
@@ -53,7 +53,7 @@ export function stepToward(state, unit, tx, ty, speed, dt) {
53
  // slow-down only (rough ground), never zero, so a unit always makes forward
54
  // progress β€” nothing can be trapped. Optional-chained so map-less test stubs
55
  // read the default 1; `allTerrain` on a def opts a unit out (forward-compat).
56
- const speedMult = state.map?.modifiers?.speedMult ?? 1;
57
  const def = UNITS[unit.type];
58
  const terrainMult = state.map?.terrain && !(def && def.allTerrain)
59
  ? sampleTerrain(state.map.terrain, unit.x, unit.y).speedMult : 1;
 
18
 
19
  import { UNITS } from "./entities.js";
20
  import { queryNeighbors } from "./grid.js";
21
+ import { sampleTerrain, sideMod } from "./map.js";
22
 
23
  const AVOID_RANGE = 30; // how far out a unit senses neighbors to steer around, beyond their combined radii
24
  const AVOID_WEIGHT = 1.6; // how strongly a sensed neighbor bends the seek direction
 
53
  // slow-down only (rough ground), never zero, so a unit always makes forward
54
  // progress β€” nothing can be trapped. Optional-chained so map-less test stubs
55
  // read the default 1; `allTerrain` on a def opts a unit out (forward-compat).
56
+ const speedMult = sideMod(state, unit.owner, "speedMult");
57
  const def = UNITS[unit.type];
58
  const terrainMult = state.map?.terrain && !(def && def.allTerrain)
59
  ? sampleTerrain(state.map.terrain, unit.x, unit.y).speedMult : 1;
engine/production.js CHANGED
@@ -7,6 +7,7 @@
7
  import { BUILDINGS, UNITS, UPGRADES, canAfford, payCost, prereqsMet, committedDoctrine } from "./entities.js";
8
  import { makeUnit } from "./state.js";
9
  import { supplyUsed, supplyCap } from "./supply.js";
 
10
 
11
  // How close a worker has to stand to actually count as building, not just
12
  // walking over. Shared with sim.js's arrival check for the 'build' order.
@@ -38,9 +39,9 @@ export function updateBuildingConstruction(state, building, dt) {
38
  building.hp = def.hp;
39
  return;
40
  }
41
- // A world's build-time modifier speeds (or slows) construction; optional-
42
- // chained so production tests' map-less states read the default 1.
43
- const bt = def.buildTime * (state.map?.modifiers?.buildTimeMult ?? 1);
44
  // The founding worker alone still builds at the original pace even if
45
  // it wanders off or dies β€” extra workers on-site are a bonus, not a
46
  // requirement.
@@ -65,9 +66,9 @@ export function updateProductionQueue(state, building, dt) {
65
  if (building.constructing || building.queue.length === 0) return;
66
  const job = building.queue[0];
67
  const def = UNITS[job.unitType];
68
- // Same build-time modifier applies to unit production (a factory world
69
- // trains faster too); optional-chained for map-less test states.
70
- const bt = def.buildTime * (state.map?.modifiers?.buildTimeMult ?? 1);
71
  job.progress += dt / bt;
72
  if (job.progress >= 1) {
73
  building.queue.shift();
 
7
  import { BUILDINGS, UNITS, UPGRADES, canAfford, payCost, prereqsMet, committedDoctrine } from "./entities.js";
8
  import { makeUnit } from "./state.js";
9
  import { supplyUsed, supplyCap } from "./supply.js";
10
+ import { sideMod } from "./map.js";
11
 
12
  // How close a worker has to stand to actually count as building, not just
13
  // walking over. Shared with sim.js's arrival check for the 'build' order.
 
39
  building.hp = def.hp;
40
  return;
41
  }
42
+ // A world's build-time modifier speeds (or slows) construction; per-side on an
43
+ // asymmetric world (sideMod reads the default 1 for map-less test states).
44
+ const bt = def.buildTime * sideMod(state, building.owner, "buildTimeMult");
45
  // The founding worker alone still builds at the original pace even if
46
  // it wanders off or dies β€” extra workers on-site are a bonus, not a
47
  // requirement.
 
66
  if (building.constructing || building.queue.length === 0) return;
67
  const job = building.queue[0];
68
  const def = UNITS[job.unitType];
69
+ // Same build-time modifier applies to unit production (a factory world trains
70
+ // faster too), per-side on an asymmetric world.
71
+ const bt = def.buildTime * sideMod(state, building.owner, "buildTimeMult");
72
  job.progress += dt / bt;
73
  if (job.progress >= 1) {
74
  building.queue.shift();
test/map.test.js CHANGED
@@ -1,6 +1,6 @@
1
  import { test } from "node:test";
2
  import assert from "node:assert/strict";
3
- import { generateMap, MAP_WIDTH, MAP_HEIGHT, PLANET_MODIFIERS } from "../engine/map.js";
4
  import { PLANET_ARCHETYPE } from "../engine/aiArchetypes.js";
5
  import { PLANETS } from "../data.js";
6
 
@@ -102,6 +102,25 @@ test("generateMap is deterministic: the same planet and rng seed reproduce the s
102
  assert.deepEqual(a.nodes, b.nodes);
103
  });
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  test("generateMap attaches the planet's modifiers (empty for the unmodified worlds)", () => {
106
  assert.deepEqual(generateMap("ferros", () => 0.5).modifiers, {}, "ferros carries no modifiers");
107
  assert.equal(generateMap("glacius", () => 0.5).modifiers.speedMult, 0.9, "glacius slows every unit");
 
1
  import { test } from "node:test";
2
  import assert from "node:assert/strict";
3
+ import { generateMap, MAP_WIDTH, MAP_HEIGHT, PLANET_MODIFIERS, sideMod } from "../engine/map.js";
4
  import { PLANET_ARCHETYPE } from "../engine/aiArchetypes.js";
5
  import { PLANETS } from "../data.js";
6
 
 
102
  assert.deepEqual(a.nodes, b.nodes);
103
  });
104
 
105
+ test("asymmetric worlds apply per-side modifiers; symmetric worlds tilt both sides equally", () => {
106
+ const oort = { map: generateMap("oort", () => 0.5) };
107
+ assert.equal(sideMod(oort, "player", "gatherMult"), 1.2, "the player's richer claim banks more");
108
+ assert.equal(sideMod(oort, "ai", "gatherMult", 1), 1, "the enemy gets no gather bonus");
109
+ assert.equal(sideMod(oort, "ai", "buildTimeMult"), 0.82, "the enemy's factory builds faster");
110
+ assert.equal(sideMod(oort, "player", "buildTimeMult"), 1, "the player builds at the normal rate");
111
+
112
+ const nimbus = { map: generateMap("nimbus", () => 0.5) };
113
+ assert.equal(sideMod(nimbus, "player", "sightMult"), 0.95, "the player sees through the thinning storm");
114
+ assert.equal(sideMod(nimbus, "ai", "sightMult"), 0.75, "the enemy stays in the murk (the world's shared value)");
115
+ assert.equal(sideMod(nimbus, "ai", "speedMult"), 1.12, "but the enemy strikes faster out of it");
116
+
117
+ // A symmetric world tilts both sides equally, and an unmodified one is neutral.
118
+ const glacius = { map: generateMap("glacius", () => 0.5) };
119
+ assert.equal(sideMod(glacius, "player", "speedMult"), sideMod(glacius, "ai", "speedMult"), "glacius slows both sides the same");
120
+ const ferros = { map: generateMap("ferros", () => 0.5) };
121
+ assert.equal(sideMod(ferros, "player", "speedMult"), 1, "ferros has no modifier -> default");
122
+ });
123
+
124
  test("generateMap attaches the planet's modifiers (empty for the unmodified worlds)", () => {
125
  assert.deepEqual(generateMap("ferros", () => 0.5).modifiers, {}, "ferros carries no modifiers");
126
  assert.equal(generateMap("glacius", () => 0.5).modifiers.speedMult, 0.9, "glacius slows every unit");