diff --git a/README.md b/README.md
index a2a0c60..38c2eac 100755
--- a/README.md
+++ b/README.md
@@ -83,18 +83,20 @@ the run length is a choice you make:
| Mode | Shape | For |
| ----- | -------- | -------------------------------------------------------- |
| Quick | 1 × 30s | A board you are still changing |
-| Deep | 10 × 10s | The usual answer — ten short walks beat one long one |
-| Max | 50 × 10s | Settling a board; mostly buys a fuller shortlist of ties |
+| Deep | 5 × 20s | The usual answer — the best of five walks, kept |
+| Max | 25 × 20s | Settling a board; mostly buys a fuller shortlist of ties |
-Ten short attempts usually beat one long one because past the first few seconds
+Several attempts usually beat one long one because past the first few seconds
the annealing temperature has fallen far enough that a long walk mostly polishes
-the basin it is already in. Attempts run as independent tasks across a worker
-pool, so the wall-clock cost of "10 × 10s" depends on how many cores the browser
-gives you — the control prints the estimate.
+the basin it is already in. Not too short, though: on a large island a 10s walk
+is still climbing, and five 20s walks beat ten 10s ones at the same cost.
+Attempts run as independent tasks across a worker pool, so the wall-clock cost
+of "5 × 20s" depends on how many cores the browser gives you — the control
+prints the estimate.
Under Singularity Isolation every attempt runs twice as long (Quick 1 × 60s,
-Deep 10 × 20s, Max 50 × 20s): a generator's output depends on its neighbours
-there, and a 10s walk does not have time to find layouts that keep them apart.
+Deep 5 × 40s, Max 25 × 40s): a generator's output depends on its neighbours
+there, and a short walk does not have time to find layouts that keep them apart.
## Quick start
diff --git a/docs/SOLVER.md b/docs/SOLVER.md
index 9686097..713d76f 100644
--- a/docs/SOLVER.md
+++ b/docs/SOLVER.md
@@ -7,14 +7,19 @@ the terminal.
```
packages/solver/
- src/solver/ the engine: types, island split, simulation, distribution, search
+ src/solver/ the engine: types, island split, simulation, distribution, and
+ placementSearch.ts, the search's entry point (solveIsland)
+ src/solver/search/ one file per search stage: seed, anneal, composition, repair,
+ rightSize, pooledCoolers, and the placement and stability primitives
+ they share
src/data/ the authored building tables, the anomaly table, the shipped island codes,
unlock resolution, mapping a game export onto all of that
src/encoding/ the blueprint wire format, and a reader for the game's export code
src/utils/ the game's number ladder
src/grid.ts tile predicates and the ASCII alphabet the tests are written in
bin/ the CLI (`npm run solve`)
- scripts/ the fixture exporter (`npm run fixtures`)
+ scripts/ the fixture exporter (`npm run fixtures`), the gap survey (`npm run survey`)
+ and the stage profiler (`npm run profile`)
tests/ vitest, run by the repo's `npm test`
fixtures/ golden fixtures, committed
```
@@ -64,9 +69,11 @@ gone; the reasoning is not.
`DistributionScratch` **must never change a number.** It is an allocation optimization and
nothing else.
-Search _tuning_ constants (`SWAP_RADIUS`, `POLISH_SHARE`, and the rest) deliberately stay private
-to `placementSearch.ts` rather than moving to `constants.ts`: they are meaningless apart from the
-stage they tune.
+Search _tuning_ constants (`SWAP_RADIUS`, `POLISH_SHARE`, and the rest) deliberately stay beside
+the stage they tune rather than moving to `constants.ts`: they are meaningless apart from it. Each
+stage of the search is a file of its own under `src/solver/search/`, and its constants are private
+to that file; the budget shares that divide the time between stages live in `placementSearch.ts`
+with `solveIsland`, and the one constant two stages share, `SWAP_RADIUS`, in `search/placement.ts`.
### `anomalies.ts` — the prestige rule changes
@@ -499,6 +506,27 @@ three. Nothing came back lower anywhere. `anomalies.test.ts` pins the seed on a
scraps: every cooler on a scrap and none beside a generator under the pool, the reverse under the
base rules, and a seed that simulates alive.
+**Under Singularity the seed is sized for the rule, and the plain seed is kept beside it.** Sized
+from the plain roster, a full-unlock hub is one ecto reactor filling one generator, and the "shared"
+fit stands three generators round one reactor — every one of them crowded, on a board where an
+isolated generator takes two reactors and makes four times the power. That seed scored 32% of the
+Singularity bound on Event Horizon where the base seed scores 60% of its own, and the first repair
+had to double it. So under a generator isolation `constructSeed` sizes each hub fit with the
+generator at its `isolated` rating (the plain entry is still what is written, and `simulateIsland`
+rates it from its neighbours as always), and claims no hub whose generator would stand beside one.
+After the first repair that seed reads 78–88% of the bound on the eight largest islands against the
+plain one's 50–64%. `solveIsland` builds and repairs the plain-sized seed too and starts from
+whichever repairs higher, the plain one on a tie — judged after the repair rather than before,
+because on Entropy Isles' 20-tile island both repair to the same five full hubs and only the plain
+one's looser arrangement leads the later stages to the six-generator optimum (2.54AD against 2.36AD
+at its 0.3s budget). The plain repair is given the time the sized one took, so this costs at most
+one more repair. Measured with the survey: at 15s × 5 runs a wash, −1.5% to +2.2% per map; at 5s × 3
+runs Event Horizon +14% and Shadowspire +18%, four maps +2–4%, Ash Bay and Magma Rift 4% and 1%
+lower inside their bands; with generator7 at tier 3 every map inside 0.8%. An un-crowding move in
+the walk — a crowded generator swapped onto a tile with no generator beside it — was measured at the
+same time and came back inside the noise band on every map (−2.0% to +1.7% at 15s × 3), so it is not
+in.
+
**Two calibrations were measured under the rules and left as they are.** `hillClimb`'s
`moveScale` — the figure the annealing temperature is derived from — is read off the **plain**
roster, so under a rule that rates a tile above it the walk runs colder than intended, up to `multiplier`
@@ -550,10 +578,13 @@ property of the rules in force rather than of the grid, so it cannot be applied
Two shapes in it are load-bearing and look like details:
-- A supplier expands **every** unvisited adjacent consumer instead of shortcutting to the sink
- when it finds one with room. The shortcut marks later consumers visited and can pick a
- different equal-length augmenting path — same maximum flow, different split, and the split
- decides which buildings clear their cooling.
+- The sink is reached by the **first** consumer with room to be enqueued, through the supplier
+ that enqueued it — the queue is FIFO, so that is the one Unity's consumer node connects from.
+ The search ends at that enqueue, which saves the expansions in between and nothing else. A
+ supplier must never pick the sink on its own terms (scan its edge list for a consumer with
+ room, or mark later consumers visited without enqueuing them): that can pick a different
+ equal-length augmenting path — same maximum flow, different split, and the split decides
+ which buildings clear their cooling.
- Supply and demand are counted **down** rather than accumulated up, because `cap - sent` is not
bit-identical to a decremented remainder and the fixtures assert exactly.
@@ -840,9 +871,14 @@ could not see would restart numbering at 1 and overwrite an untagged run.
`--attempts` runs a batch session: N whole solves through a worker pool, with a leaderboard of
distinct layouts at the end. Ctrl-C stops it and still prints the report.
-Cores are spent on two axes and the flags pick which. A **run** farms its islands out to the pool
-— the same search either way, since each island still gets its own slice (`islandBudgetsS`) of
-the budget, so only wall clock changes. A **session** runs whole attempts in the pool with islands
+Cores are spent on two axes and the flags pick which. A **run** farms its islands out to the pool,
+each with its own slice (`islandBudgetsS`) of the budget. When every island has a worker of its
+own the slices are stretched until the largest is the whole budget (`fitBudgetsToPool`), since a
+run that fits the pool lasts as long as its largest slice anyway — the app's Quick does the same.
+That makes it a different search from the serial fallback (one island, or a pool of one), which
+keeps the summed split, so on a multi-island board the core count decides how long each island is
+searched. `npm run survey` keeps the summed split too: it measures one attempt's slices, which is
+what a queued Deep or Max run spends. A **session** runs whole attempts in the pool with islands
serial inside each; the pool already owns the cores, and a nested pool per island would
oversubscribe the machine.
diff --git a/package.json b/package.json
index 1fd1cca..a37ce3b 100755
--- a/package.json
+++ b/package.json
@@ -30,7 +30,8 @@
"build:solver": "npm run build --workspace @reactor2/solver",
"solve": "npm run solve --workspace @reactor2/solver --",
"fixtures": "npm run fixtures --workspace @reactor2/solver --",
- "survey": "npm run survey --workspace @reactor2/solver --"
+ "survey": "npm run survey --workspace @reactor2/solver --",
+ "profile": "npm run profile --workspace @reactor2/solver --"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.2.0",
diff --git a/packages/solver/CLAUDE.md b/packages/solver/CLAUDE.md
index a95312b..c80f468 100644
--- a/packages/solver/CLAUDE.md
+++ b/packages/solver/CLAUDE.md
@@ -159,7 +159,7 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it,
at 98% of its bound — is unchanged. The `coolerMultiplier` has to be earned
back before any of that shows.
- **The seed is the one stage that branches on the rule.** Under a pool
+ **The seed branches on the rule.** Under a pool
`constructSeed` builds every hub engine-first — a phantom cooler sizes the
fit, so the neighbour slots all go to reactors — charges the cooling as a
board-wide debt in each candidate's rank, reserves the tiles for it, and pays
@@ -245,6 +245,16 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it,
The neighbour scan reads the _original_ placement, since the test is on what a
neighbour is and no rating changes that — so there is no order to get right.
+ **The seed branches on this rule too.** Sized from the plain roster, a
+ full-unlock hub is one reactor filling one generator and the shared fit
+ stands three generators round one reactor, all crowded; that seed scored 32%
+ of the bound on Event Horizon. Under a generator isolation `constructSeed`
+ sizes each hub with the generator at `isolated` and claims no hub whose
+ generator would stand beside one, and `solveIsland` repairs the plain-sized
+ seed too and starts from whichever repairs higher, the plain one on a tie.
+ `docs/SOLVER.md` has the measurements: a wash at 15s, +14-18% on the two
+ largest maps at 5s.
+
**The bonus is on a capacity, which is why the rule is close to power-neutral
at full unlocks.** `isolated` scales a generator's _intake_, and a generator
fed by the reactors it already had simply fills to `1/isolated` and produces
@@ -290,7 +300,7 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it,
figures are read: a step of the walk writes one or two tiles and then
simulates the island, which reads every occupied tile, so the write is some
25x less work and `simulateIsland` stays untouched. `put()` in
- `placementSearch.ts` is the only way a building enters a placement, so the
+ `search/placement.ts` is the only way a building enters a placement, so the
bonus cannot be missed at one of three dozen sites — and a miss would be
silent, since the layout would simply be worth less than it is.
@@ -575,8 +585,13 @@ is the entry point. `planSolve()` does the shared setup:
**seed → repair → composition retarget → (annealing → repair)\* → pruning →
right-sizing**. The starred pair repeats until the deadline: repair's slice is
reserved before the layout exists and converges long before its cap, so what
- it hands back becomes another short walk rather than idle time. The reasoning
- for each stage is in the file header and `docs/SOLVER.md`; the short
+ it hands back becomes another short walk rather than idle time. Each stage
+ is a file under `search/` (`seed`, `anneal`, `composition`, `repair`,
+ `rightSize`, `pooledCoolers`, over the shared `placement` and `stability`
+ primitives); `placementSearch.ts` keeps `solveIsland`, the budget split,
+ the deterministic replay and `internals`, and stays the one module anything
+ outside imports the search from. The reasoning for each stage is in
+ `placementSearch.ts`'s header and `docs/SOLVER.md`; the short
version is that seeding builds self-sufficient hubs and systematically misses
layouts where hubs _share_ a cooler or reactor, and the other stages cover
that from different directions.
@@ -619,11 +634,13 @@ building sits on a tile and nothing else. Tied layouts go through it too.
**`distribution.ts` is a port of Unity's `FlowNetwork.cs`.** Two shapes in it are
load-bearing and look like details:
-- A supplier expands **every** unvisited adjacent consumer instead of
- shortcutting to the sink when it finds one with room. The shortcut marks later
- consumers visited and can pick a different equal-length augmenting path — same
- maximum flow, different split, and the split decides which buildings clear
- their cooling.
+- The sink is reached by the **first** consumer with room to be enqueued,
+ through the supplier that enqueued it — the queue is FIFO, so that is the one
+ Unity's consumer node connects from, and the search ends at that enqueue. A
+ supplier must never pick the sink on its own terms (scan its edge list for a
+ consumer with room, or mark later consumers visited without enqueuing them):
+ that can pick a different equal-length augmenting path — same maximum flow,
+ different split, and the split decides which buildings clear their cooling.
- Supply and demand are counted **down** rather than accumulated up, because
`cap - sent` is not bit-identical to a decremented remainder and the golden
fixtures assert exactly.
@@ -738,6 +755,16 @@ it warns if the first three differ. Expect about ±0.8% per map at 15s × 5 runs
so a gain smaller than that needs more runs to show; "no island got worse" is
the claim it can make at any size.
+**For a change to what a stage costs, use the profiler before and after.**
+`npm run profile` times each stage of the search on the largest shipped island
+under each anomaly — the bound, the composition targets, seed construction,
+one simulation, the collector's tie check, one full repair — on the same
+deterministic seed layout both times, so two runs taken back to back on an
+idle machine measure the same work. It is how the seed was found to cost some 40x
+what its comment claimed, and how a repair was found to cost more than the
+polish share of a Quick attempt on a phone: the fixtures cannot see either,
+since neither changes a number.
+
The survey solves at full unlocks unless told otherwise. `--unlock ID=LEVEL`
(0-based, `-1` to lock) surveys another roster, and a rule that rates one role
needs one: at full unlocks Singularity's generator bonus buys a capacity no
diff --git a/packages/solver/bin/solve.ts b/packages/solver/bin/solve.ts
index e577ed6..8d3fb7d 100644
--- a/packages/solver/bin/solve.ts
+++ b/packages/solver/bin/solve.ts
@@ -44,6 +44,7 @@ import {
import { verify } from "../src/solver/report";
import {
buildOptimizationResult,
+ fitBudgetsToPool,
planSolve,
solve,
} from "../src/solver/solver";
@@ -142,9 +143,12 @@ function offsetSeed(
/**
* One solve, with the islands farmed out to `pool` when there is more than one.
*
- * Islands never interact, so this is the same search either way — each island
- * still gets its own slice of the budget (`islandBudgetsS`). Only the wall clock
- * changes: concurrently it is ~max(slice) instead of the whole budget.
+ * Islands never interact, so each one gets its own slice of the budget
+ * (`islandBudgetsS`) either way. Not the same search, though: when every island
+ * has a worker the pooled run stretches the slices until the largest is the
+ * whole budget (`fitBudgetsToPool`), while the serial fallback — one island, or
+ * a pool of one — keeps them summing to it. So on a board of several islands
+ * the core count decides how long each one is searched.
*/
async function solveWithPool(
grid: Tile[][],
@@ -176,6 +180,10 @@ async function solveWithPool(
placements: [],
powerOutput: 0,
}));
+ // When every island has a worker of its own the run lasts as long as its
+ // largest slice, so the app's Quick rule stretches them to the budget;
+ // `fitBudgetsToPool` checks that and leaves a run that queues alone.
+ const budgetsS = fitBudgetsToPool(plan.budgetsS, timeBudgetS, 1, pool.size);
await pool.run(
plan.islands.map((island, i) => ({
@@ -184,7 +192,7 @@ async function solveWithPool(
kind: "island" as const,
island,
effectiveBuildings: plan.effectiveBuildings,
- budgetS: plan.budgetsS[i],
+ budgetS: budgetsS[i],
seed: plan.seeds[i],
// The resolved definition rather than the id: the plan has already
// resolved it, and a worker re-resolving would be a second reading of
diff --git a/packages/solver/package.json b/packages/solver/package.json
index 1242ebc..d94c09a 100644
--- a/packages/solver/package.json
+++ b/packages/solver/package.json
@@ -38,6 +38,7 @@
"solve": "tsx bin/solve.ts",
"fixtures": "tsx scripts/exportFixtures.ts",
"survey": "tsx scripts/gapSurvey.ts",
+ "profile": "tsx scripts/profile.ts",
"prepublishOnly": "npm run build"
}
}
diff --git a/packages/solver/scripts/gapSurvey.ts b/packages/solver/scripts/gapSurvey.ts
index 7a99c36..c5ac170 100644
--- a/packages/solver/scripts/gapSurvey.ts
+++ b/packages/solver/scripts/gapSurvey.ts
@@ -18,8 +18,10 @@
* of a bound that is not tight.
*
* Islands are solved one task each through the CLI's own worker pool, with
- * the slice of `--time` `planSolve` gives them — the same budget a real run
- * spends on that island — so a figure here is a figure the app would get.
+ * the slice of `--time` `planSolve` gives them — the same budget a queued app
+ * run spends on that island. A run that fits the app's pool stretches those
+ * slices (`fitBudgetsToPool`), so on a board of several large islands this
+ * undercounts what a Quick run gets.
*
* **Every map is surveyed with its obstacles cleared.** Rocks, trees and ponds
* are removable in the game and become grass here; water and the transformer
diff --git a/packages/solver/scripts/profile.ts b/packages/solver/scripts/profile.ts
new file mode 100644
index 0000000..61e7de9
--- /dev/null
+++ b/packages/solver/scripts/profile.ts
@@ -0,0 +1,273 @@
+/**
+ * Measures the search's hot path on the largest shipped island, stage by
+ * stage.
+ *
+ * npm run profile (from the repo root)
+ * npm run profile -- --anomaly cryo_nexus --anomaly none
+ * npm run profile -- --map 3
+ *
+ * `npm run survey` says what a real run finds; this says what it costs. One
+ * figure per stage — the bound, the composition targets, seed construction,
+ * one simulation, the collector's tie check, one full repair, and how many
+ * annealing steps a second buys — so a change to any of them can be judged
+ * against the figure it started from rather than against a feeling.
+ *
+ * Every figure is wall-clock on this machine under this load, so compare two
+ * runs taken back to back with nothing else running, never two taken on
+ * different days. The stages are taken in solve order on the same seed layout,
+ * and the seed is deterministic (`constructMultiStartSeed` draws no fresh
+ * randomness), so two runs of the same code measure the same work.
+ *
+ * It runs under `tsx` with esbuild's `keepNames` wrapper on every function,
+ * which the Vite bundle the app ships does not carry; the absolute figures are
+ * therefore a little pessimistic for the browser, and the ratios between
+ * stages are what to read.
+ */
+
+import { ANOMALIES, getAnomaly } from "../src/data/anomalies";
+import { BUILDINGS, allUpgradesUnlocked } from "../src/data/buildings";
+import { getEffectiveBuildings } from "../src/data/effectiveBuildings";
+import { decodeBlueprint } from "../src/encoding/blueprint";
+import { MAPS, MAPS_BY_NUM, type CliMap } from "../bin/maps";
+import { AlternateCollector } from "../src/solver/alternates";
+import { buildIslandContext } from "../src/solver/context";
+import {
+ canCoolDirectProducer,
+ estimateTotalMaxPower,
+ splitGridIntoIslands,
+} from "../src/solver/island";
+import { Pacer } from "../src/solver/pacer";
+import { internals } from "../src/solver/placementSearch";
+import { Rng } from "../src/solver/rng";
+import { simulateIsland } from "../src/solver/simulate";
+import type {
+ AnomalyId,
+ EffectiveBuilding,
+ IslandSubGrid,
+ Placement,
+} from "../src/solver/types";
+import { formatNumber } from "../src/utils/formatters";
+
+const DEFAULT_ANOMALIES: AnomalyId[] = [
+ "none",
+ "cryo_nexus",
+ "tidal_ascendancy",
+ "singularity_isolation",
+];
+/** How long the annealing walk is run to read its step rate. */
+const WALK_MS = 2000;
+
+const USAGE = `usage: profile [--map NUM]... [--anomaly ID]...
+
+ --map NUM map to profile (repeatable; default: the largest island of
+ every map, which is one island per anomaly)
+ --anomaly ID anomaly to profile (repeatable; default: ${DEFAULT_ANOMALIES.join(", ")})
+`;
+
+interface Args {
+ maps: CliMap[];
+ anomalies: AnomalyId[];
+}
+
+function parseArgs(argv: string[]): Args | null {
+ const maps: CliMap[] = [];
+ const anomalies: AnomalyId[] = [];
+ for (let i = 0; i < argv.length; i++) {
+ const flag = argv[i];
+ if (flag === "--help" || flag === "-h") return null;
+ const raw = argv[++i];
+ if (raw === undefined) return null;
+ if (flag === "--map") {
+ const map = MAPS_BY_NUM.get(Number(raw));
+ if (map === undefined) {
+ console.error(`unknown map ${raw}`);
+ return null;
+ }
+ maps.push(map);
+ } else if (flag === "--anomaly") {
+ if (!ANOMALIES.some((a) => a.id === raw)) {
+ console.error(`unknown anomaly ${raw}`);
+ return null;
+ }
+ anomalies.push(raw as AnomalyId);
+ } else {
+ return null;
+ }
+ }
+ return {
+ maps: maps.length > 0 ? maps : MAPS.filter((m) => m.num !== 0),
+ anomalies: anomalies.length > 0 ? anomalies : DEFAULT_ANOMALIES,
+ };
+}
+
+const roster = getEffectiveBuildings(BUILDINGS, allUpgradesUnlocked());
+const byRole = (
+ type: EffectiveBuilding["type"],
+ key: "effectiveValue" | "energy",
+): EffectiveBuilding[] =>
+ roster.filter((b) => b.type === type).sort((a, b) => b[key] - a[key]);
+const reactors = byRole("reactor", "effectiveValue");
+const generators = byRole("generator", "effectiveValue");
+const coolers = byRole("cooler", "effectiveValue");
+const directProducers = byRole("direct_producer", "energy");
+
+const ms = (from: number): string =>
+ `${(performance.now() - from).toFixed(1)} ms`;
+
+/** A shortlist of ten layouts, each a few tiles from `seed`, to tie against. */
+function filledCollector(seed: Placement, n: number): AlternateCollector {
+ const collector = new AlternateCollector();
+ const rng = new Rng(1);
+ for (let i = 0; i < 10; i++) {
+ const copy = seed.slice();
+ let cleared = 0;
+ while (cleared < 6) {
+ const t = rng.int(n);
+ if (copy[t] !== null) {
+ copy[t] = null;
+ cleared++;
+ }
+ }
+ collector.offer(1, copy);
+ }
+ return collector;
+}
+
+async function largestIsland(
+ maps: CliMap[],
+ anomalyId: AnomalyId,
+): Promise<{ map: CliMap; island: IslandSubGrid } | null> {
+ const anomaly = getAnomaly(anomalyId);
+ let best: { map: CliMap; island: IslandSubGrid } | null = null;
+ for (const map of maps) {
+ const { grid } = await decodeBlueprint(map.code);
+ for (const island of splitGridIntoIslands(
+ grid,
+ canCoolDirectProducer(roster),
+ anomaly,
+ )) {
+ if (best === null || island.tileCount > best.island.tileCount)
+ best = { map, island };
+ }
+ }
+ return best;
+}
+
+async function profile(
+ map: CliMap,
+ island: IslandSubGrid,
+ anomalyId: AnomalyId,
+) {
+ const anomaly = getAnomaly(anomalyId);
+ const ctx = buildIslandContext(
+ island.grid,
+ island.buildable,
+ anomaly,
+ island.waterAdjacent,
+ );
+ console.log(`\n== ${anomaly.name}: ${map.name}, ${ctx.n} tiles ==`);
+
+ let t0 = performance.now();
+ const bound = estimateTotalMaxPower([island], roster, anomaly);
+ console.log(
+ `bound ${ms(t0).padStart(10)} (${formatNumber(bound)})`,
+ );
+
+ t0 = performance.now();
+ const targets = internals.targetCompositions(
+ ctx.n,
+ reactors,
+ generators,
+ coolers,
+ internals.generatorCapacityTable(island, ctx, generators[0]),
+ );
+ console.log(
+ `targetCompositions ${ms(t0).padStart(10)} (${targets.length} kept)`,
+ );
+
+ t0 = performance.now();
+ const seed = internals.constructMultiStartSeed(
+ ctx,
+ reactors,
+ generators,
+ coolers,
+ directProducers,
+ performance.now() + 1e9,
+ );
+ console.log(`seed ${ms(t0).padStart(10)}`);
+
+ const sims = 20000;
+ t0 = performance.now();
+ for (let i = 0; i < sims; i++) simulateIsland(seed, ctx);
+ const simUs = ((performance.now() - t0) * 1000) / sims;
+ console.log(
+ `simulateIsland ${`${simUs.toFixed(1)} us`.padStart(10)} (${Math.round(1e6 / simUs / 1000)}k/s)`,
+ );
+
+ const collector = filledCollector(seed, ctx.n);
+ const ties = 20000;
+ t0 = performance.now();
+ for (let i = 0; i < ties; i++) collector.accepts(seed);
+ const tieUs = ((performance.now() - t0) * 1000) / ties;
+ console.log(
+ `collector.accepts ${`${tieUs.toFixed(1)} us`.padStart(10)} (${Math.round((tieUs / simUs) * 100)}% of a simulation, on a tie)`,
+ );
+
+ const candidates: (EffectiveBuilding | null)[] = [
+ ...reactors.slice(0, 3),
+ generators[0],
+ coolers[0],
+ ...directProducers.slice(0, 1),
+ null,
+ ];
+ t0 = performance.now();
+ const polished = await internals.greedyPolish(
+ seed,
+ ctx,
+ candidates,
+ performance.now() + 1e9,
+ new Pacer(),
+ );
+ console.log(
+ `greedyPolish on seed ${ms(t0).padStart(10)} (${formatNumber(simulateIsland(seed, ctx).totalPower)} -> ${formatNumber(polished.power)})`,
+ );
+
+ let steps = 0;
+ const pacer = new Pacer({ shouldStop: () => (steps++, false) });
+ t0 = performance.now();
+ await internals.hillClimb(
+ polished.placement,
+ ctx,
+ reactors,
+ generators,
+ coolers,
+ directProducers,
+ WALK_MS,
+ new Rng(7),
+ pacer,
+ undefined,
+ new AlternateCollector(),
+ );
+ // The walk yields every 50ms and asks `shouldStop` each time, so this is
+ // its yield count; the step rate is read off the simulation cost instead,
+ // since every step is one simulation plus the tie check.
+ console.log(
+ `hillClimb ${ms(t0).padStart(10)} (${steps} yields; about ${Math.round(1e6 / (simUs + tieUs) / 1000)}k steps/s at most)`,
+ );
+}
+
+async function main(): Promise {
+ const args = parseArgs(process.argv.slice(2));
+ if (args === null) {
+ console.log(USAGE);
+ process.exitCode = 1;
+ return;
+ }
+ for (const anomalyId of args.anomalies) {
+ const found = await largestIsland(args.maps, anomalyId);
+ if (found === null) continue;
+ await profile(found.map, found.island, anomalyId);
+ }
+}
+
+await main();
diff --git a/packages/solver/src/solver/alternates.ts b/packages/solver/src/solver/alternates.ts
index 0102826..57de29c 100755
--- a/packages/solver/src/solver/alternates.ts
+++ b/packages/solver/src/solver/alternates.ts
@@ -189,7 +189,12 @@ export class AlternateCollector {
* converged walk mostly produces, and it only ever reads the layout's tiles.
*/
accepts(placement: Placement): boolean {
- if (this.#layouts.has(placementKey(placement))) return false;
+ // An identical board is at distance 0 and fails the loop below, so the key
+ // lookup is only needed when the distance rule is off. It is an O(n)
+ // string build, asked on every tied step of a converged walk, where it was
+ // a fifth of the cost of the simulation that produced the tie.
+ if (this.#minDistance <= 0 && this.#layouts.has(placementKey(placement)))
+ return false;
for (const held of this.#layouts.values()) {
if (
placementDistance(placement, held, this.#minDistance) <
diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts
index b2fc8d3..59f179a 100755
--- a/packages/solver/src/solver/context.ts
+++ b/packages/solver/src/solver/context.ts
@@ -41,7 +41,6 @@ export class DistributionScratch {
* exactly. */
readonly remSupplierCap: Float64Array;
readonly remConsumerCap: Float64Array;
- readonly eligible: Int32Array;
/** CSR-style adjacency: items[start[i] .. start[i + 1]) */
readonly supAdjStart: Int32Array;
@@ -52,6 +51,11 @@ export class DistributionScratch {
readonly visitedToken: Int32Array;
readonly parentNode: Int32Array;
readonly queue: Int32Array;
+ /**
+ * Augmenting Repair's suppliers with supply left, for the component being
+ * repaired, in ascending index order. Compacted as suppliers run dry.
+ */
+ readonly liveSuppliers: Int32Array;
/**
* Per-supplier FairShare round budget: the number of suppliers sharing that
@@ -80,7 +84,6 @@ export class DistributionScratch {
this.consumerReceived = new Float64Array(n);
this.remSupplierCap = new Float64Array(n);
this.remConsumerCap = new Float64Array(n);
- this.eligible = new Int32Array(n);
this.supAdjStart = new Int32Array(n + 1);
this.supAdjItems = new Int32Array(maxAdj);
this.conAdjStart = new Int32Array(n + 1);
@@ -88,6 +91,7 @@ export class DistributionScratch {
this.visitedToken = new Int32Array(totalNodes);
this.parentNode = new Int32Array(totalNodes);
this.queue = new Int32Array(totalNodes);
+ this.liveSuppliers = new Int32Array(n);
this.supRoundBudget = new Int32Array(n);
this.componentOf = new Int32Array(n);
this.componentMembers = new Int32Array(n);
diff --git a/packages/solver/src/solver/distribution.ts b/packages/solver/src/solver/distribution.ts
index 2c2ad11..c547a44 100755
--- a/packages/solver/src/solver/distribution.ts
+++ b/packages/solver/src/solver/distribution.ts
@@ -18,11 +18,17 @@ import type { IslandContext } from "./context";
*
* Two shapes here are load-bearing and neither is arbitrary:
*
- * - A supplier does **not** jump straight to the sink on finding a consumer
- * with room. It enqueues the consumer and lets the consumer node connect,
- * because the shortcut marks later consumers visited and can select a
- * different equal-length augmenting path — same maximum flow, different
- * split, and the split is what decides which buildings clear their cooling.
+ * - The sink is reached by the consumer with room that was enqueued
+ * **first**, with the supplier that enqueued it as its parent — the queue is
+ * FIFO, so that is the one Unity's consumer node connects from, and every
+ * node before it on the path was already parented when it was enqueued.
+ * The search here ends at that enqueue rather than at the dequeue, which
+ * skips the expansions in between and nothing else. What it must never do
+ * is let a supplier pick the sink on its own terms — scanning its edge list
+ * for a consumer with room, or marking later consumers visited without
+ * enqueuing them — because that can select a different equal-length
+ * augmenting path: same maximum flow, different split, and the split is
+ * what decides which buildings clear their cooling.
* - Supply and demand are counted DOWN. `cap - sent` accumulated separately is
* not bit-identical to a decremented remainder, and the parity fixtures are
* asserted exactly.
@@ -256,6 +262,7 @@ export function runDistribution(
const visitedToken = d.visitedToken;
const parentNode = d.parentNode;
const queue = d.queue;
+ const live = d.liveSuppliers;
visitedToken.fill(0, 0, sink + 1);
let token = 0;
@@ -279,59 +286,75 @@ export function runDistribution(
const memberFrom = componentStart[comp];
const memberTo = componentStart[comp + 1];
+ // The suppliers the source would enqueue, kept in ascending index order
+ // and compacted as they run dry. Supply only ever goes down, so a supplier
+ // dropped here is never enqueued again — the same set, in the same order,
+ // as a scan of the whole component, read from the few that are left rather
+ // than from every member on every search. Under a Cryo Nexus board the
+ // heat graph repairs with some sixty searches over a component of thirty
+ // suppliers, eight of which still hold anything.
+ let liveCount = 0;
+ for (let m = memberFrom; m < memberTo; m++) {
+ const sIdx = componentMembers[m];
+ if (supRem[sIdx] > EPS) live[liveCount++] = sIdx;
+ }
+
for (;;) {
- let anyLeft = false;
- for (let m = memberFrom; m < memberTo; m++) {
- if (supRem[componentMembers[m]] > EPS) {
- anyLeft = true;
- break;
+ token += 1;
+ visitedToken[source] = token;
+
+ // Source -> every supplier with remaining supply: the source's own
+ // expansion, done in place rather than through the queue.
+ let qTail = 0;
+ let keep = 0;
+ for (let m = 0; m < liveCount; m++) {
+ const sIdx = live[m];
+ if (supRem[sIdx] > EPS) {
+ live[keep++] = sIdx;
+ visitedToken[sIdx] = token;
+ parentNode[sIdx] = source;
+ queue[qTail++] = sIdx;
}
}
- if (!anyLeft) break;
+ liveCount = keep;
+ if (liveCount === 0) break;
- token += 1;
- visitedToken[source] = token;
- queue[0] = source;
let qHead = 0;
- let qTail = 1;
let reachedSink = false;
while (qHead < qTail && !reachedSink) {
const node = queue[qHead++];
- if (node === source) {
- for (let m = memberFrom; m < memberTo; m++) {
- const sIdx = componentMembers[m];
- if (visitedToken[sIdx] === token) continue;
- if (supRem[sIdx] > EPS) {
- visitedToken[sIdx] = token;
- parentNode[sIdx] = source;
- queue[qTail++] = sIdx;
- }
- }
- } else if (node < numSuppliers) {
- // Supplier -> every unvisited adjacent consumer. Forward residual is
- // unbounded, and the sink is deliberately NOT reached from here.
+ if (node < numSuppliers) {
+ // Supplier -> every unvisited adjacent consumer, in edge order.
+ // Forward residual is unbounded. The first consumer with room to be
+ // enqueued is the one that would connect to the sink when dequeued
+ // (FIFO), with this supplier as its parent, so the search is over
+ // the moment it is enqueued; the file header says why it must be
+ // the first *enqueued* and not any other consumer with room.
for (let k = supAdjStart[node]; k < supAdjStart[node + 1]; k++) {
- const conNode = numSuppliers + supAdjItems[k];
+ const cIdx = supAdjItems[k];
+ const conNode = numSuppliers + cIdx;
if (visitedToken[conNode] === token) continue;
visitedToken[conNode] = token;
parentNode[conNode] = node;
queue[qTail++] = conNode;
+ if (demRem[cIdx] > EPS) {
+ parentNode[sink] = conNode;
+ reachedSink = true;
+ break;
+ }
}
- } else if (node < source) {
+ } else {
+ // A consumer without room (one with room ended the search above):
+ // walk back-edges to the suppliers already sending to it. The
+ // visited test comes first because it is the cheaper load, and
+ // either test skipping the edge is the same skip.
const cIdx = node - numSuppliers;
- if (visitedToken[sink] !== token && demRem[cIdx] > EPS) {
- visitedToken[sink] = token;
- parentNode[sink] = node;
- reachedSink = true;
- break;
- }
-
for (let m = conAdjStart[cIdx]; m < conAdjStart[cIdx + 1]; m++) {
const sIdx = conAdjItems[m];
- if (flow[sIdx * numConsumers + cIdx] <= EPS) continue;
if (visitedToken[sIdx] === token) continue;
+ if (flow[sIdx * numConsumers + cIdx] <= EPS) continue;
visitedToken[sIdx] = token;
parentNode[sIdx] = node;
queue[qTail++] = sIdx;
diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts
index 62f7e66..c4b049a 100755
--- a/packages/solver/src/solver/island.ts
+++ b/packages/solver/src/solver/island.ts
@@ -810,44 +810,151 @@ function bestHeatForEngineTiles(
* A shore reactor may feed an inland generator and the other way round — the
* bound relaxes adjacency away, so only the two totals matter — which makes
* this `max over the split of min(reactor heat, generator intake)` with two
- * integer splits instead of one. For each split of the shore tiles the inland
- * split is the one-line crossing the single-class version already checks, so
- * the search is linear in the shore count rather than quadratic.
+ * integer splits instead of one. For each split of the shore tiles, reactor
+ * heat rises with the inland reactor count and generator intake falls with
+ * it, so the inland split is their crossing, found by bisection.
+ *
+ * `shoreSums[k]` and `inlandSums[k]` are what the best `k` tiles of each class
+ * can take in as generators (`intakeSums`): generators stand on the tiles with
+ * the best neighbourhoods and reactors on the rest, which is the arrangement
+ * that credits each class most.
*/
function bestHeatForMixedEngineTiles(
nEngine: number,
shoreEngine: number,
inland: RosterFigures,
shore: RosterFigures,
+ shoreSums: Float64Array,
+ inlandSums: Float64Array,
): number {
if (nEngine < 2) return 0.0;
const inlandEngine = nEngine - shoreEngine;
- const slope = inland.rVal + inland.gVal;
+ const rVal = inland.rVal;
let best = 0.0;
+ // The last inland reactor count at which heat has not overtaken intake; the
+ // peak of the min is there or one past it. Each shore reactor added raises
+ // the heat side and lowers the intake side, so it only ever moves down: it
+ // is carried from one shore split to the next and galloped down from there,
+ // which finds the same count a fresh bisection would at a fraction of the
+ // cost. This runs once per engine split, so it is the bound's inner loop.
+ let hi = inlandEngine;
for (let shoreReact = 0; shoreReact <= shoreEngine; shoreReact++) {
const shoreHeat = shoreReact * shore.rVal;
- const shoreIntake = (shoreEngine - shoreReact) * shore.gVal;
- // min(shoreHeat + R·x, shoreIntake + G·(inlandEngine − x)) peaks at the
- // crossing; the ends cover a crossing outside the inland range.
- const crossing =
- slope > 0
- ? Math.floor(
- (shoreIntake + inland.gVal * inlandEngine - shoreHeat) / slope,
- )
- : 0;
- for (const inlandReact of [crossing, crossing + 1, 0, inlandEngine]) {
- if (inlandReact < 0 || inlandReact > inlandEngine) continue;
- const heat = Math.min(
- shoreHeat + inlandReact * inland.rVal,
- shoreIntake + (inlandEngine - inlandReact) * inland.gVal,
+ const shoreIntake = shoreSums[shoreEngine - shoreReact];
+ if (
+ hi >= 0 &&
+ shoreHeat + hi * rVal > shoreIntake + inlandSums[inlandEngine - hi]
+ ) {
+ // `hi` fails: find a passing count below it, then bisect between.
+ let fail = hi;
+ let step = 1;
+ let pass = hi - step;
+ while (
+ pass >= 0 &&
+ shoreHeat + pass * rVal > shoreIntake + inlandSums[inlandEngine - pass]
+ ) {
+ fail = pass;
+ step *= 2;
+ pass = hi - step;
+ }
+ if (pass < 0) pass = -1;
+ while (fail - pass > 1) {
+ const mid = (pass + fail) >> 1;
+ if (
+ shoreHeat + mid * rVal <=
+ shoreIntake + inlandSums[inlandEngine - mid]
+ )
+ pass = mid;
+ else fail = mid;
+ }
+ hi = pass;
+ }
+ for (let x = hi; x <= hi + 1; x++) {
+ if (x < 0 || x > inlandEngine) continue;
+ const h = Math.min(
+ shoreHeat + x * rVal,
+ shoreIntake + inlandSums[inlandEngine - x],
);
- if (heat > best) best = heat;
+ if (h > best) best = h;
}
}
return best;
}
+/**
+ * Running sums of what one class of tile can take in as generators, best tile
+ * first: `sums[k]` is the most heat `k` generators of that class can absorb.
+ *
+ * Each is held to the smaller of `gVal` and what **its own** neighbours can
+ * feed and cool, counted by class: `neighbourHeatCap` with eight neighbours at
+ * the shore rating credited every inland generator with a ring of rated
+ * reactors and coolers that only the tiles one step from the coast can have.
+ * Once the cap binds — a generator tier outrunning its reactors and coolers,
+ * as generator7 does over any reactor this side of the top tiers — that was
+ * most of what the bound had to spare. Neighbours are whole buildings, so the
+ * split of each class between reactors and coolers is enumerated; a tile has
+ * at most eight, which keeps the table to 45 entries.
+ */
+function intakeSums(
+ island: IslandSubGrid,
+ shoreClass: boolean,
+ gVal: number,
+ inland: RosterFigures,
+ shore: RosterFigures,
+ wasteRatio: number,
+ pooledCooling: boolean,
+): Float64Array {
+ const most = CHEBYSHEV_DIRECTIONS.length;
+ const capFor = new Map();
+ const cap = (s: number, i: number): number => {
+ const key = s * (most + 1) + i;
+ const known = capFor.get(key);
+ if (known !== undefined) return known;
+ let top = 0.0;
+ for (let rs = 0; rs <= s; rs++) {
+ for (let ri = 0; ri <= i; ri++) {
+ const heat = rs * shore.rVal + ri * inland.rVal;
+ const cooling = (s - rs) * shore.cVal + (i - ri) * inland.cVal;
+ const h = pooledCooling ? heat : Math.min(heat, cooling / wasteRatio);
+ if (h > top) top = h;
+ }
+ }
+ // Under a pool every neighbour may be a reactor, so the loop's all-reactor
+ // corner already holds the answer; outside one it is cooled to nothing.
+ const value = Math.min(gVal, top);
+ capFor.set(key, value);
+ return value;
+ };
+
+ const { width, height, buildable, waterAdjacent } = island;
+ const caps: number[] = [];
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const at = y * width + x;
+ if (buildable[at] !== 1 || (waterAdjacent[at] === 1) !== shoreClass)
+ continue;
+ let s = 0;
+ let i = 0;
+ for (const [dx, dy] of CHEBYSHEV_DIRECTIONS) {
+ const nx = x + dx;
+ const ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
+ const n = ny * width + nx;
+ if (buildable[n] !== 1) continue;
+ if (waterAdjacent[n] === 1) s++;
+ else i++;
+ }
+ caps.push(cap(s, i));
+ }
+ }
+ caps.sort((a, b) => b - a);
+
+ const sums = new Float64Array(caps.length + 1);
+ for (let k = 0; k < caps.length; k++) sums[k + 1] = sums[k] + caps[k];
+ return sums;
+}
+
/**
* `estimateIslandMaxPower` for an island whose tiles come in two classes: the
* `shoreTiles` a terrain bonus rates at `multiplier`, and the rest at 1.
@@ -882,12 +989,13 @@ function bestHeatForMixedEngineTiles(
* mixed board has to allow the greedier of each.
*/
function estimateMixedIslandMaxPower(
- buildableTiles: number,
+ island: IslandSubGrid,
shoreTiles: number,
roster: EffectiveBuilding[],
multiplier: number,
pooledCooling: boolean,
): number {
+ const buildableTiles = island.tileCount;
if (buildableTiles <= 0) return 0.0;
const inlandTiles = buildableTiles - shoreTiles;
@@ -905,13 +1013,10 @@ function estimateMixedIslandMaxPower(
const inlandDpWaste = inland.hasDp ? inland.dpWaste : 0.0;
const shoreDpWaste = shore.hasDp ? shore.dpWaste : 0.0;
- // One cap for both classes, on the strongest neighbour either class can
- // offer: which class a tile falls in is a property of *that* tile, and an
- // inland generator is perfectly free to have shore reactors and shore
- // coolers around it. Taking the best of each is the only reading that stays
- // a bound, and it costs nothing — the cap is homogeneous, so a terrain
- // multiplier moves it and the generator it caps by the same factor and it
- // goes on not binding. See `neighbourHeatCap`.
+ // A first, island-wide cap on the strongest neighbours either class can
+ // offer. It does bind — a strong generator tier outruns any eight
+ // neighbours — and `intakeSums` below tightens it per tile, by how many of
+ // that tile's neighbours really are shore. See `neighbourHeatCap`.
const gTake = neighbourHeatCap(
maxNeighbourTiles(buildableTiles),
Math.max(inland.rVal, shore.rVal),
@@ -922,6 +1027,27 @@ function estimateMixedIslandMaxPower(
inland.gVal = Math.min(inland.gVal, gTake);
shore.gVal = Math.min(shore.gVal, gTake);
+ // Each generator is held to what its own neighbours can feed — see
+ // `intakeSums`, which is where this bound's looseness under a binding cap was.
+ const shoreSums = intakeSums(
+ island,
+ true,
+ shore.gVal,
+ inland,
+ shore,
+ wasteRatio,
+ pooledCooling,
+ );
+ const inlandSums = intakeSums(
+ island,
+ false,
+ inland.gVal,
+ inland,
+ shore,
+ wasteRatio,
+ pooledCooling,
+ );
+
// The same 3-and-2 floors `estimateIslandMaxPower` states, for the same
// reason, and they must not be allowed to disagree with it.
const canHub = inland.rVal > 0 && inland.gVal > 0 && buildableTiles >= 3;
@@ -961,6 +1087,8 @@ function estimateMixedIslandMaxPower(
shoreEngine,
inland,
shore,
+ shoreSums,
+ inlandSums,
);
// Engine tiles that can't form a reactor+generator pair.
if (nEngine > 0 && heatCap <= 0.0) continue;
@@ -1197,13 +1325,7 @@ function estimateIslandBound(
return Math.min(
plain * scale,
- estimateMixedIslandMaxPower(
- island.tileCount,
- rated,
- roster,
- scale,
- pooledCooling,
- ),
+ estimateMixedIslandMaxPower(island, rated, roster, scale, pooledCooling),
);
}
diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts
index f8df05d..22395d3 100755
--- a/packages/solver/src/solver/placementSearch.ts
+++ b/packages/solver/src/solver/placementSearch.ts
@@ -1,2570 +1,149 @@
-import { EPS } from "./constants";
-import {
- generatorEnergyRatio,
- generatorPowerAndWaste,
- generatorWasteRatio,
- wasteIsCovered,
-} from "./physics";
-import { buildIslandContext, type IslandContext } from "./context";
-import { generatorCapacities, isolationRoom } from "./island";
-import { Pacer, type IslandProgress } from "./pacer";
-import { AlternateCollector, isDistinctLayout, powerTies } from "./alternates";
-import { Rng, randomSeed } from "./rng";
-import { simulateIsland, type SimPlacedBuilding } from "./simulate";
-import type {
- AnomalyDefinition,
- EffectiveBuilding,
- IslandLayout,
- IslandSubGrid,
- PlacedBuilding,
- Placement,
- SearchHooks,
-} from "./types";
-
-/**
- * Placement search for a single island:
- *
- * seed -> repair -> composition retarget -> (annealing -> repair)* -> pruning
- * -> right-sizing
- *
- * The `annealing -> repair` pair repeats until the deadline. Repair's slice is
- * reserved before the layout it will run on exists, and on a post-anneal layout
- * it almost always converges in one fruitless sweep, so whatever it hands back
- * becomes another short walk rather than idle time at the end of the budget.
- *
- * Seed construction lays out self-sufficient "hubs" — one generator plus
- * exactly the reactors and coolers it needs on its own. That is a good starting
- * shape but a systematically incomplete one: real layouts do better by letting
- * neighbouring hubs SHARE, one cooler absorbing waste from two generators or
- * one reactor feeding two, which frees tiles for more producers. Nothing in
- * seed construction can express that, and on the 67-tile map 1 island it caps
- * out 7% below the best known layout.
- *
- * The two stages around it exist to cover that blind spot from different sides:
- *
- * - `greedyPolish` is steepest ascent over single-tile changes. Annealing alone
- * was supposed to find these, and does on small islands, but on a large
- * packed island almost every single-tile change costs several percent, so the
- * walk drifts downhill and rarely climbs back — measured there, only 1
- * evaluation in 26,000 beat its own starting layout while 20 stable improving
- * moves sat a single move away.
- * - `targetCompositions` / `arrangeComposition` attack the other half. Deciding
- * WHAT to build is a counting problem with an exact answer, so it is computed
- * directly rather than searched for; the search is then left with the
- * genuinely hard part, which is where to put it.
- *
- * Annealing keeps its place between them for the moves neither can make: it can
- * cross a barrier that no single change improves upon.
- *
- * Right-sizing is the odd one out: it is not part of the search and cannot find
- * a better layout. It runs once the layout is final and swaps each building for
- * the smallest tier that still carries the load that layout gives it, because
- * power is blind to the difference between a cooler running flat out and one
- * running at 2% — and the player is not.
- *
- * **The stability rule.** A solved layout must be fully stable: every generator
- * and direct producer it places has to actually run. A layout can score higher
- * by parking reactor heat in a generator that is never cooled — nothing then
- * has to cool that share of the waste — and it is still the wrong answer, so
- * only stable layouts are ever recorded as best. "Pruning never reduces power"
- * is NOT a valid invariant here; "the returned layout is stable" is.
- *
- * **Under an upgrade plan "stable" means stable at every step.** The search
- * scores at the plan's target, but every gate that admits a layout — the best
- * record, repair, the composition swaps, pruning, right-sizing, the tie
- * shortlist and `stabilize` itself — also asks `planHolds`, so a layout that
- * overheats at any tier on the way is never kept. Without a plan that call
- * returns at once and nothing here changes.
- */
-
-const MAX_TOP_REACTOR_COPIES = 3;
-const MAX_SECOND_REACTOR_COPIES = 3;
-/**
- * The cooling a pooled seed sizes its hubs against: cooling from nowhere, so
- * that no hub ever needs a cooler beside it. It has to be `Infinity` and not
- * merely large — a fit counts `ceil(waste / cooling)` coolers, and any finite
- * figure makes that one, not none. `snapToAuthoredPrecision` passes it through
- * and the pool's `factor` compares with `<`, so the scratch simulation the
- * shared fit runs against it is sound. Never written onto a real placement.
- */
-const POOL_COOLER_VALUE = Infinity;
-
-/** How many reactor tiers the local-repair pass considers per tile. */
-const POLISH_REACTOR_TIERS = 3;
-/** Share of the time budget reserved for repair before and after annealing. */
-const POLISH_SHARE = 0.3;
-/** How many candidate compositions to try arranging, best-scoring first. */
-const COMPOSITION_TARGETS = 3;
-/** Share of the time budget spent arranging those compositions. */
-const COMPOSITION_SHARE = 0.2;
-/** Max Chebyshev distance between two tiles considered for an arrangement swap. */
-const SWAP_RADIUS = 2;
-/**
- * The closing repair is reserved a share of the budget up front, then handed
- * back whatever it does not use (see `solveIsland`). These bound the handback:
- * the repair is left twice its measured cost as headroom, but never more than
- * half of what is left — on a big island one sweep costs seconds, and reserving
- * twice that would strand more time than it protects. A reclaimed round shorter
- * than the floor is not worth its own setup.
- */
-const RECLAIM_POLISH_MARGIN = 2.0;
-const RECLAIM_MAX_RESERVE_FRACTION = 0.5;
-const RECLAIM_MIN_ROUND_SHARE = 0.01;
-const RECLAIM_MIN_ROUND_MS = 50;
-
-export interface IslandSolution extends IslandLayout {
- /**
- * Other layouts this island's search finished tied with, best-power-first
- * and never including the primary. Empty when the search found no second
- * arrangement at the same power. See `alternates.ts` — they are gathered by
- * watching the walk, and every one of them is stable and pruned exactly as
- * the primary is.
- */
- alternates?: IslandLayout[];
-}
-
-// ---------------------------------------------------------------------------
-// Placement helpers
-// ---------------------------------------------------------------------------
-
-function emptyPlacement(n: number): Placement {
- return new Array(n).fill(null);
-}
-
-/**
- * Writes a building onto a tile, rated for that tile.
- *
- * The one way a building enters a placement, so that a terrain bonus cannot be
- * missed at one of three dozen sites — and a miss would be silent, since the
- * layout would simply be worth less than it is. `ctx.rate` is the identity
- * under every rule but a terrain bonus, and idempotent under that one, so this
- * is equally correct for a fresh placement, a swap between two tiles, and the
- * restore that undoes a rejected move.
- *
- * Clearing a tile stays a plain `= null`: a null carries no rating, so there is
- * nothing to resolve and nothing to get wrong.
- */
-function put(
- placement: Placement,
- ctx: IslandContext,
- tile: number,
- building: EffectiveBuilding | null,
-): void {
- placement[tile] = ratedFor(ctx, tile, building);
-}
-
-/**
- * Exactly what `put` would write onto `tile` — the building as that tile rates
- * it, or `null`.
- *
- * This is how a move asks "would writing this change anything?", and it has to
- * be asked in the tile's own units: a placement holds RATED buildings, so
- * comparing a plain roster entry against one is never equal on a scaled tile,
- * and the guard that should have skipped the move instead writes back an
- * identical object, runs a full `simulateIsland`, and hands `accept` a delta of
- * zero — which it "accepts". No number moves, and the walk quietly spends a
- * slice of a budget whose only product is steps per second.
- *
- * The comparison is exact rather than approximate because `ctx.rate` caches one
- * object per (scale, roster entry) pair, so two tiles of the same scale holding
- * the same building hold the *same* object — and it is cheap for the same
- * reason: a lookup, not a multiply.
- */
-function ratedFor(
- ctx: IslandContext,
- tile: number,
- building: EffectiveBuilding | null,
-): EffectiveBuilding | null {
- return building === null ? null : ctx.rate(tile, building);
-}
-
-function copyInto(dst: Placement, src: Placement): void {
- for (let i = 0; i < dst.length; i++) dst[i] = src[i];
-}
-
-function isReactor(b: EffectiveBuilding): boolean {
- return b.type === "reactor";
-}
-
-function isDirectProducer(b: EffectiveBuilding): boolean {
- return b.type === "direct_producer";
-}
-
-/** Scratch for `generatorPowerAndWaste`, reused for the same reason as in `simulate.ts`. */
-const conversion = { power: 0.0, waste: 0.0 };
-
-function chebyshev(ctx: IslandContext, a: number, b: number): number {
- return Math.max(
- Math.abs(ctx.xs[a] - ctx.xs[b]),
- Math.abs(ctx.ys[a] - ctx.ys[b]),
- );
-}
-
-/** The tile's Chebyshev-adjacent buildable neighbors that are still unclaimed. */
-function availableNeighbors(
- tile: number,
- available: Uint8Array,
- ctx: IslandContext,
-): number[] {
- const out: number[] = [];
- const nb = ctx.neighbors[tile];
- for (let k = 0; k < nb.length; k++) {
- if (available[nb[k]]) out.push(nb[k]);
- }
- return out;
-}
-
-function toPlacedBuildings(rows: SimPlacedBuilding[]): PlacedBuilding[] {
- return rows.map((r) => ({
- x: r.x,
- y: r.y,
- buildingId: r.buildingId,
- baseValue: r.baseValue,
- powerGenerated: r.powerGenerated,
- heatProduced: r.heatProduced,
- heatConsumed: r.heatConsumed,
- wasteHeatGenerated: r.wasteHeatGenerated,
- coolingProvided: r.coolingProvided,
- coolingReceived: r.coolingReceived,
- }));
-}
-
-// ---------------------------------------------------------------------------
-// Stability
-// ---------------------------------------------------------------------------
-
-/**
- * Tiles holding a generator or direct producer that generates no power —
- * either because it is overheating (waste above the cooling routed to it) or
- * because it never received any heat at all.
- *
- * A layout containing one of these is not a valid answer: the goal is a fully
- * stable grid where every producer runs, so these tiles are wasted at best and
- * represent an overheating building at worst.
- */
-function offlineProducers(
- placement: Placement,
- rows: SimPlacedBuilding[],
-): number[] {
- const offline: number[] = [];
- for (let i = 0; i < rows.length; i++) {
- const p = rows[i];
- const building = placement[p.idx];
- if (building === null) continue;
- const producer =
- building.type === "generator" || isDirectProducer(building);
- if (producer && p.powerGenerated <= EPS) offline.push(p.idx);
- }
- return offline;
-}
-
-/**
- * A supplier sending out less than the tile it stands on was rated to carry —
- * the walk's cue that a generator beside it would have somewhere to put the
- * rest.
- *
- * Against `ratedValue`, never `baseValue`: `heatProduced` is what the
- * distribution actually sent, capped by the tile's RATING, so measuring it
- * against the authored tier compares a scaled delivery with an unscaled ceiling
- * and only calls a reactor under-fed below 1/k fill for a tile rated k — on a
- * Tidal shore k is its `multiplier`, under a maxed Stellar Forge the
- * research's. The move then stops firing on much of what it exists for, with
- * no number anywhere disagreeing. It is a named function so that the reading
- * can be pinned by a test rather than only stated here.
- */
-function isUnderFed(row: SimPlacedBuilding): boolean {
- return row.heatProduced > EPS && row.heatProduced < row.ratedValue - EPS;
-}
-
-/** Allocation-free `offlineProducers(...).length > 0`, for the search's inner loops. */
-function anyOfflineProducer(
- placement: Placement,
- rows: SimPlacedBuilding[],
-): boolean {
- for (let i = 0; i < rows.length; i++) {
- const p = rows[i];
- const building = placement[p.idx];
- if (building === null) continue;
- const producer =
- building.type === "generator" || isDirectProducer(building);
- if (producer && p.powerGenerated <= EPS) return true;
- }
- return false;
-}
-
-/**
- * An upgrade plan: the rosters a layout has to stay stable under besides the
- * one it is scored at — the tiers the player holds today and every step on the
- * way to the target. Each step is keyed by building id, because a placement
- * holds the target roster's objects and the step's own figures are found by
- * id; a building the step does not have stands on its tile as nothing.
- *
- * A step is simulated with the rating its tiles give it (`ctx.rate`), so a
- * terrain bonus or a cooling pool applies at every step exactly as it does at
- * the target.
- */
-interface UpgradePlan {
- steps: Map[];
- /** Scratch the step layout is written into, one per island. */
- buffer: Placement;
-}
-
-/**
- * Held beside the context rather than on it, so the context type — and every
- * stage that takes one — is untouched when there is no plan, which is always
- * the case for the fixtures.
- */
-const upgradePlans = new WeakMap();
-
-/**
- * The producers that go offline under the first plan step the layout fails, or
- * null when it runs at every step. Free when the island has no plan.
- */
-function planFailure(
- placement: Placement,
- ctx: IslandContext,
-): number[] | null {
- const plan = upgradePlans.get(ctx);
- if (plan === undefined) return null;
- const buffer = plan.buffer;
- for (const step of plan.steps) {
- for (let t = 0; t < ctx.n; t++) {
- const b = placement[t];
- const at = b === null ? undefined : step.get(b.id);
- buffer[t] = at === undefined ? null : ctx.rate(t, at);
- }
- const sim = simulateIsland(buffer, ctx);
- const offline = offlineProducers(buffer, sim.placements);
- if (offline.length > 0) return offline;
- }
- return null;
-}
-
-/** Whether the layout runs at every step of the island's plan. */
-function planHolds(placement: Placement, ctx: IslandContext): boolean {
- return planFailure(placement, ctx) === null;
-}
-
-interface StableLayout {
- placement: Placement;
- power: number;
- rows: SimPlacedBuilding[];
-}
-
-/**
- * Drops producers that generate no power until every remaining one is online.
- *
- * This has to iterate rather than delete them all in one pass: removing an
- * offline generator hands the heat it was absorbing to its neighbours, which
- * can push THEIR waste past what their coolers cover and take them offline in
- * turn. Power can legitimately fall as a result — a layout that only scores
- * well by parking heat in an overheating building is not a layout we want.
- */
-function stabilize(placement: Placement, ctx: IslandContext): StableLayout {
- const current = placement.slice();
- let sim = simulateIsland(current, ctx);
-
- for (;;) {
- let offline = offlineProducers(current, sim.placements);
- // Stable at the target is not enough under a plan: a producer that
- // overheats at any step goes too, and so on until every step runs.
- if (offline.length === 0) offline = planFailure(current, ctx) ?? [];
- if (offline.length === 0) {
- return {
- placement: current,
- power: sim.totalPower,
- rows: sim.placements,
- };
- }
- for (const tile of offline) current[tile] = null;
- sim = simulateIsland(current, ctx);
- }
-}
-
-// ---------------------------------------------------------------------------
-// Seed construction
-// ---------------------------------------------------------------------------
-
-interface HubFit {
- generator: EffectiveBuilding;
- reactor: EffectiveBuilding;
- r: number;
- c: number;
- power: number;
- /** The waste the hub makes at that fill — what a pooled seed charges for. */
- waste: number;
-}
-
-function bestHubFit(
- availableSlots: number,
- generators: EffectiveBuilding[],
- reactors: EffectiveBuilding[],
- cooler: EffectiveBuilding,
-): HubFit | null {
- let best: HubFit | null = null;
- for (const generator of generators) {
- for (const reactor of reactors) {
- const maxR = Math.min(
- availableSlots,
- Math.ceil(generator.effectiveValue / reactor.effectiveValue),
- );
- for (let r = 1; r <= maxR; r++) {
- const hIn = Math.min(
- generator.effectiveValue,
- r * reactor.effectiveValue,
- );
- generatorPowerAndWaste(generator, hIn, conversion);
- const waste = conversion.waste;
- const c =
- cooler.effectiveValue > 0
- ? Math.ceil(waste / cooler.effectiveValue)
- : 0;
- if (r + c <= availableSlots) {
- const power = conversion.power;
- if (best === null || power > best.power) {
- best = { generator, reactor, r, c, power, waste };
- }
- }
- if (hIn >= generator.effectiveValue - EPS) break;
- }
- }
- }
- return best;
-}
-
-interface DpFit {
- dp: EffectiveBuilding;
- c: number;
- power: number;
- waste: number;
-}
-
-function bestDpFit(
- availableSlots: number,
- directProducers: EffectiveBuilding[],
- cooler: EffectiveBuilding,
-): DpFit | null {
- let best: DpFit | null = null;
- for (const dp of directProducers) {
- const waste = dp.waste;
- const c =
- cooler.effectiveValue > 0 ? Math.ceil(waste / cooler.effectiveValue) : 0;
- if (c > availableSlots) continue;
- const power = dp.energy;
- if (best === null || power > best.power) best = { dp, c, power, waste };
- }
- return best;
-}
-
-/** `itertools.combinations` over a list, in the same lexicographic order. */
-function* combinations(pool: number[], k: number): Generator {
- const n = pool.length;
- if (k > n || k < 0) return;
- const idx = new Array(k);
- for (let i = 0; i < k; i++) idx[i] = i;
- for (;;) {
- const out = new Array(k);
- for (let i = 0; i < k; i++) out[i] = pool[idx[i]];
- yield out;
-
- let i = k - 1;
- while (i >= 0 && idx[i] === i + n - k) i--;
- if (i < 0) return;
- idx[i]++;
- for (let j = i + 1; j < k; j++) idx[j] = idx[j - 1] + 1;
- }
-}
-
-interface SharedFit {
- power: number;
- reactorTiles: number[];
- reactorTiers: EffectiveBuilding[];
- genPositions: number[];
- coolerPositions: number[];
- waste: number;
-}
-
-/**
- * A multi-generator arrangement around one reactor tile, for rosters where a
- * single top-tier reactor overfills a generator: the surplus heat is only
- * usable if two or more generators share that reactor, which the plain hub fit
- * can never express.
- */
-function bestSharedReactorFit(
- tile: number,
- neighbors: number[],
- reactors: EffectiveBuilding[],
- generator: EffectiveBuilding,
- cooler: EffectiveBuilding,
- ctx: IslandContext,
- scratch: Placement,
- pooled = false,
-): SharedFit | null {
- if (reactors.length === 0) return null;
- const topReactor = reactors[0];
- const secondReactor = reactors.length > 1 ? reactors[1] : null;
-
- // Under a pool `cooler` is the phantom and `c` is 0, so the arrangement is
- // simulated with no cooler beside it — and a coolerless board is offline.
- // The pool is stood in for by the phantom on any tile outside the
- // neighbourhood; where the island has none, the arrangement cannot be
- // measured and is not offered.
- let poolTile = -1;
- if (pooled) {
- const local = new Set(neighbors);
- for (let t = 0; t < ctx.n; t++) {
- if (t !== tile && !local.has(t)) {
- poolTile = t;
- break;
- }
- }
- if (poolTile < 0) return null;
- }
-
- const connectivity = new Map();
- for (const n of neighbors) {
- let count = 0;
- for (const m of neighbors) {
- if (m !== n && chebyshev(ctx, n, m) <= 1) count++;
- }
- connectivity.set(n, count);
- }
- // Ascending tile index is ascending spatial key, so this is
- // `(connectivity, spatial_key)` exactly.
- const byConnectivityAsc = [...neighbors].sort(
- (a, b) => connectivity.get(a)! - connectivity.get(b)! || a - b,
- );
-
- let best: SharedFit | null = null;
- const maxK1 = Math.min(MAX_TOP_REACTOR_COPIES, 1 + neighbors.length);
-
- for (let k1 = 1; k1 <= maxK1; k1++) {
- const extraTopNeeded = k1 - 1;
- const maxK2 =
- secondReactor !== null
- ? Math.min(MAX_SECOND_REACTOR_COPIES, neighbors.length - extraTopNeeded)
- : 0;
-
- for (let k2 = 0; k2 <= maxK2; k2++) {
- const nExtraReactors = extraTopNeeded + k2;
- if (nExtraReactors > neighbors.length) continue;
-
- let totalValue = k1 * topReactor.effectiveValue;
- if (k2 && secondReactor) totalValue += k2 * secondReactor.effectiveValue;
-
- const g = Math.ceil(totalValue / generator.effectiveValue);
- if (g < 2 || nExtraReactors + g + 1 > neighbors.length) continue;
-
- const absorbed = Math.min(totalValue, g * generator.effectiveValue);
- const wasteTotal = absorbed * generatorWasteRatio(generator);
- const c =
- cooler.effectiveValue > 0
- ? Math.ceil(wasteTotal / cooler.effectiveValue)
- : 0;
- if (nExtraReactors + g + c > neighbors.length) continue;
-
- const extraPositions = byConnectivityAsc.slice(0, nExtraReactors);
- const extraTopPositions = extraPositions.slice(0, extraTopNeeded);
- const extraSecondPositions = extraPositions.slice(
- extraTopNeeded,
- nExtraReactors,
- );
- const extraSet = new Set(extraPositions);
- const pool = neighbors.filter((n) => !extraSet.has(n));
-
- for (const genPositions of combinations(pool, g)) {
- const genSet = new Set(genPositions);
- const leftover = pool.filter((n) => !genSet.has(n));
- leftover.sort((a, b) => {
- let adjA = 0;
- let adjB = 0;
- for (const gpos of genPositions) {
- if (chebyshev(ctx, a, gpos) <= 1) adjA++;
- if (chebyshev(ctx, b, gpos) <= 1) adjB++;
- }
- return adjB - adjA || a - b;
- });
- const coolerPositions = leftover.slice(0, c);
-
- const touched: number[] = [];
- put(scratch, ctx, tile, topReactor);
- touched.push(tile);
- for (const n of extraTopPositions) {
- put(scratch, ctx, n, topReactor);
- touched.push(n);
- }
- for (const n of extraSecondPositions) {
- put(scratch, ctx, n, secondReactor);
- touched.push(n);
- }
- for (const n of genPositions) {
- put(scratch, ctx, n, generator);
- touched.push(n);
- }
- for (const n of coolerPositions) {
- put(scratch, ctx, n, cooler);
- touched.push(n);
- }
- if (pooled) {
- put(scratch, ctx, poolTile, cooler);
- touched.push(poolTile);
- }
-
- const { totalPower } = simulateIsland(scratch, ctx);
- for (const n of touched) scratch[n] = null;
-
- if (totalPower > EPS && (best === null || totalPower > best.power)) {
- const reactorTiles = [
- tile,
- ...extraTopPositions,
- ...extraSecondPositions,
- ];
- const reactorTiers: EffectiveBuilding[] = [
- topReactor,
- ...extraTopPositions.map(() => topReactor),
- ...extraSecondPositions.map(() => secondReactor!),
- ];
- best = {
- power: totalPower,
- reactorTiles,
- reactorTiers,
- genPositions,
- coolerPositions,
- waste: wasteTotal,
- };
- }
- }
- }
- }
-
- return best;
-}
-
-interface CandidateEntry {
- /**
- * Power PER TILE CLAIMED, which is what candidates are ranked by — not raw
- * power. A candidate that spends more tiles (e.g. a multi-reactor "shared"
- * setup) can have higher raw power than a compact one-reactor hub while
- * actually being a worse use of the island's limited tiles: it wins a single
- * greedy round yet leaves the *rest* of the island worse off, since those
- * extra tiles are no longer available for other, more efficient hubs.
- * Ranking by power/tile keeps the greedy choice locally optimal in the
- * currency that is actually scarce.
- */
- powerPerTile: number;
- kind: "hub" | "dp" | "shared";
- tile: number;
- neighbors: number[];
- hub?: HubFit;
- dp?: DpFit;
- shared?: SharedFit;
-}
-
-function constructSeed(
- buildableOrder: number[],
- ctx: IslandContext,
- reactors: EffectiveBuilding[],
- generators: EffectiveBuilding[],
- coolers: EffectiveBuilding[],
- directProducers: EffectiveBuilding[],
- deadlineMs: number,
- pooled = false,
-): Placement {
- const placement = emptyPlacement(ctx.n);
- const available = new Uint8Array(ctx.n);
- for (const tile of buildableOrder) available[tile] = 1;
- let numAvailable = buildableOrder.length;
-
- const topReactor = reactors.length > 0 ? reactors[0] : null;
- const topGenerator = generators.length > 0 ? generators[0] : null;
- const topCooler = coolers.length > 0 ? coolers[0] : null;
- const canHub =
- topReactor !== null && topGenerator !== null && topCooler !== null;
- const canDp = directProducers.length > 0 && topCooler !== null;
- const trySharedReactor =
- canHub &&
- Math.ceil(topReactor!.effectiveValue / topGenerator!.effectiveValue) >= 2;
-
- // Under a cooling pool a hub is sized with a phantom cooler that covers any
- // waste from nowhere, so every neighbour slot goes to reactors and no cooler
- // is written beside a hub. The cooling it really needs is charged as a
- // board-wide debt instead: counted into each candidate's rank as the
- // fractional tiles it will cost, reserved out of the free tiles so a hub can
- // never crowd out the coolers that keep it online, and paid off after the
- // hubs are down onto the least-connected free tiles — the scraps no hub can
- // use, which under the pool are worth exactly what a well-connected tile is.
- const fitCooler: EffectiveBuilding | null =
- pooled && topCooler !== null
- ? { ...topCooler, effectiveValue: POOL_COOLER_VALUE }
- : topCooler;
- const coolerValue =
- topCooler !== null ? ctx.rateRole(topCooler).effectiveValue : 0;
- /** Coolers the pool needs for `waste`, at the rated figure. */
- const coolersFor = (waste: number): number =>
- waste > EPS && coolerValue > 0 ? Math.ceil(waste / coolerValue) : 0;
- /** What the board's producers are owed so far, from the simulation. */
- let debt = 0;
- const actualWaste = (): number => {
- const rows = simulateIsland(placement, ctx, true).placements;
- let sum = 0;
- for (let i = 0; i < rows.length; i++) sum += rows[i].wasteHeatGenerated;
- return sum;
- };
- /** Whether claiming `tiles` still leaves room for the coolers `waste` needs. */
- const reserves = (tiles: number, waste: number): boolean =>
- !pooled || numAvailable - tiles >= coolersFor(debt + waste);
- /** Tiles a candidate charges for: its own, plus the pool's share under one. */
- const cost = (tiles: number, waste: number): number =>
- pooled ? tiles + waste / coolerValue : tiles;
- /** The tiles each claim took, newest last, for the pooled backstop. */
- const groups: number[][] = [];
-
- const hubFitCache = new Map();
- const dpFitCache = new Map();
- const sharedScratch = emptyPlacement(ctx.n);
-
- const hubFit = (slots: number): HubFit | null => {
- let fit = hubFitCache.get(slots);
- if (fit === undefined) {
- fit = canHub ? bestHubFit(slots, generators, reactors, fitCooler!) : null;
- hubFitCache.set(slots, fit);
- }
- return fit;
- };
-
- const dpFit = (slots: number): DpFit | null => {
- let fit = dpFitCache.get(slots);
- if (fit === undefined) {
- fit = canDp ? bestDpFit(slots, directProducers, fitCooler!) : null;
- dpFitCache.set(slots, fit);
- }
- return fit;
- };
-
- const candidateCache = new Array(ctx.n).fill(null);
- const stale = new Uint8Array(ctx.n);
- for (const tile of buildableOrder) stale[tile] = 1;
-
- while (numAvailable > 0) {
- if (performance.now() >= deadlineMs) break;
-
- const staleList = buildableOrder.filter((tile) => stale[tile] === 1);
- for (const tile of staleList) {
- if (performance.now() >= deadlineMs) break;
-
- const neighbors = availableNeighbors(tile, available, ctx);
- let entry: CandidateEntry | null = null;
- const consider = (candidate: CandidateEntry) => {
- if (entry === null || candidate.powerPerTile > entry.powerPerTile)
- entry = candidate;
- };
-
- // Under a pool the fit shrinks until its reactors and its coolers both
- // fit in what is left; under the base rules the first fit is the one.
- let hub = hubFit(neighbors.length);
- while (hub !== null && !reserves(1 + hub.r, hub.waste)) {
- hub = hub.r > 1 ? hubFit(hub.r - 1) : null;
- }
- if (hub !== null) {
- const tilesUsed = 1 + hub.r + hub.c;
- if (hub.power > EPS && tilesUsed > 0) {
- consider({
- powerPerTile: hub.power / cost(tilesUsed, hub.waste),
- kind: "hub",
- tile,
- neighbors,
- hub,
- });
- }
- }
-
- const dp = dpFit(neighbors.length);
- if (dp !== null && reserves(1, dp.waste)) {
- const tilesUsed = 1 + dp.c;
- if (dp.power > EPS && tilesUsed > 0) {
- consider({
- powerPerTile: dp.power / cost(tilesUsed, dp.waste),
- kind: "dp",
- tile,
- neighbors,
- dp,
- });
- }
- }
-
- if (trySharedReactor && neighbors.length >= 3) {
- const shared = bestSharedReactorFit(
- tile,
- neighbors,
- reactors,
- topGenerator!,
- fitCooler!,
- ctx,
- sharedScratch,
- pooled,
- );
- if (shared !== null) {
- const tilesUsed =
- shared.reactorTiles.length +
- shared.genPositions.length +
- shared.coolerPositions.length;
- if (tilesUsed > 0 && reserves(tilesUsed, shared.waste)) {
- consider({
- powerPerTile: shared.power / cost(tilesUsed, shared.waste),
- kind: "shared",
- tile,
- neighbors,
- shared,
- });
- }
- }
- }
-
- candidateCache[tile] = entry;
- }
-
- stale.fill(0);
-
- let best: CandidateEntry | null = null;
- for (const tile of buildableOrder) {
- if (!available[tile]) continue;
- const entry = candidateCache[tile];
- if (
- entry !== null &&
- (best === null || entry.powerPerTile > best.powerPerTile)
- ) {
- best = entry;
- }
- }
- if (best === null) break;
-
- if (pooled) {
- // The debt has grown since a candidate untouched by the last claim was
- // sized, so it may no longer leave room for its coolers. Re-size every
- // open tile against the debt as it stands: a candidate sized against the
- // current debt passes this at once, so the loop cannot spin.
- const tiles =
- best.kind === "hub"
- ? 1 + best.hub!.r
- : best.kind === "dp"
- ? 1
- : best.shared!.reactorTiles.length +
- best.shared!.genPositions.length;
- const waste =
- best.kind === "hub"
- ? best.hub!.waste
- : best.kind === "dp"
- ? best.dp!.waste
- : best.shared!.waste;
- if (!reserves(tiles, waste)) {
- for (const tile of buildableOrder) if (available[tile]) stale[tile] = 1;
- continue;
- }
- }
-
- const claimed: number[] = [];
- if (best.kind === "hub") {
- const { generator, reactor, r, c } = best.hub!;
- put(placement, ctx, best.tile, generator);
- claimed.push(best.tile);
- for (let i = 0; i < r; i++) {
- put(placement, ctx, best.neighbors[i], reactor);
- claimed.push(best.neighbors[i]);
- }
- for (let i = r; i < r + c; i++) {
- put(placement, ctx, best.neighbors[i], topCooler);
- claimed.push(best.neighbors[i]);
- }
- } else if (best.kind === "shared") {
- const shared = best.shared!;
- for (let i = 0; i < shared.reactorTiles.length; i++) {
- put(placement, ctx, shared.reactorTiles[i], shared.reactorTiers[i]);
- claimed.push(shared.reactorTiles[i]);
- }
- for (const n of shared.genPositions) {
- put(placement, ctx, n, topGenerator);
- claimed.push(n);
- }
- for (const n of shared.coolerPositions) {
- put(placement, ctx, n, topCooler);
- claimed.push(n);
- }
- } else {
- const { dp, c } = best.dp!;
- put(placement, ctx, best.tile, dp);
- claimed.push(best.tile);
- for (let i = 0; i < c; i++) {
- put(placement, ctx, best.neighbors[i], topCooler);
- claimed.push(best.neighbors[i]);
- }
- }
-
- for (const tile of claimed) {
- if (available[tile]) {
- available[tile] = 0;
- numAvailable--;
- }
- candidateCache[tile] = null;
- }
- for (const tile of claimed) {
- const nb = ctx.neighbors[tile];
- for (let k = 0; k < nb.length; k++) {
- if (available[nb[k]]) stale[nb[k]] = 1;
- }
- }
- if (pooled) {
- groups.push(claimed);
- // The simulated figure rather than the sum of the fits' estimates: two
- // hubs down beside each other share reactors, and a generator fed by a
- // neighbour's surplus makes waste no fit counted.
- debt = actualWaste();
- }
- }
-
- if (pooled && topCooler !== null) {
- // Pay the debt off, least-connected tile first: a one-tile scrap holds a
- // cooler as well as any tile does, and nothing else can use it. Ascending
- // index breaks ties so the fill is a function of the board alone.
- const free = buildableOrder
- .filter((tile) => available[tile] === 1)
- .sort(
- (a, b) => ctx.neighbors[a].length - ctx.neighbors[b].length || a - b,
- );
- let cooling = 0;
- let next = 0;
- const payDown = () => {
- while (next < free.length && !wasteIsCovered(debt, cooling)) {
- const tile = free[next++];
- put(placement, ctx, tile, topCooler);
- cooling += placement[tile]!.effectiveValue;
- }
- };
- payDown();
-
- // A short pool is an all-offline board, and `stabilize` would then delete
- // every producer. So the board has to come up here: one more cooler while
- // there is a tile for it, and failing that the newest hub is taken back
- // and its tiles spent on the pool instead.
- while (debt > EPS && simulateIsland(placement, ctx).totalPower <= EPS) {
- if (next < free.length) {
- const tile = free[next++];
- put(placement, ctx, tile, topCooler);
- cooling += placement[tile]!.effectiveValue;
- continue;
- }
- const group = groups.pop();
- if (group === undefined) break;
- for (const tile of group) placement[tile] = null;
- for (const tile of group) free.push(tile);
- debt = actualWaste();
- payDown();
- }
- }
-
- return placement;
-}
-
-function constructMultiStartSeed(
- ctx: IslandContext,
- reactors: EffectiveBuilding[],
- generators: EffectiveBuilding[],
- coolers: EffectiveBuilding[],
- directProducers: EffectiveBuilding[],
- deadlineMs: number,
-): Placement {
- const buildable = Array.from(ctx.tiles);
-
- const passes = (pooled: boolean): { seed: Placement; power: number } => {
- let bestSeed = constructSeed(
- buildable,
- ctx,
- reactors,
- generators,
- coolers,
- directProducers,
- deadlineMs,
- pooled,
- );
- let bestPower = simulateIsland(bestSeed, ctx).totalPower;
-
- if (bestPower <= EPS) {
- // The deadline elapsed mid-construction and left a seed that powers
- // nothing. One full greedy pass costs ~2ms even on the largest island in
- // the game, so finish it: refining a degenerate seed wastes the whole
- // budget and can return an empty layout for a solvable island.
- bestSeed = constructSeed(
- buildable,
- ctx,
- reactors,
- generators,
- coolers,
- directProducers,
- performance.now() + 1000,
- pooled,
- );
- bestPower = simulateIsland(bestSeed, ctx).totalPower;
- }
-
- if (performance.now() >= deadlineMs - 50 || buildable.length < 6)
- return { seed: bestSeed, power: bestPower };
-
- const reversed = [...buildable].reverse();
- const seedRev = constructSeed(
- reversed,
- ctx,
- reactors,
- generators,
- coolers,
- directProducers,
- deadlineMs,
- pooled,
- );
- const powerRev = simulateIsland(seedRev, ctx).totalPower;
- if (powerRev > bestPower + EPS) {
- bestPower = powerRev;
- bestSeed = seedRev;
- }
-
- if (performance.now() < deadlineMs - 50) {
- const rng = new Rng(42);
- const shuffled = [...buildable];
- rng.shuffle(shuffled);
- const seedRand = constructSeed(
- shuffled,
- ctx,
- reactors,
- generators,
- coolers,
- directProducers,
- deadlineMs,
- pooled,
- );
- const powerRand = simulateIsland(seedRand, ctx).totalPower;
- if (powerRand > bestPower + EPS) {
- bestPower = powerRand;
- bestSeed = seedRand;
- }
- }
-
- return { seed: bestSeed, power: bestPower };
- };
-
- const local = passes(false);
- if (ctx.anomaly.rule !== "shared_cooling") return local.seed;
-
- // Under a pool the same three orders are run engine-first as well, and the
- // strongest seed of the six starts the search. The local passes stay in:
- // they are what the search started from before the pooled ones existed, and
- // a pooled seed that comes back weaker is not an improvement.
- const pooled = passes(true);
- return pooled.power > local.power + EPS ? pooled.seed : local.seed;
-}
-
-// ---------------------------------------------------------------------------
-// Annealing
-// ---------------------------------------------------------------------------
-
-function randomCandidate(
- pools: EffectiveBuilding[][],
- rng: Rng,
-): EffectiveBuilding | null {
- if (pools.length === 0 || rng.random() < 0.15) return null;
- const pool = rng.choice(pools);
- if (pool.length === 1 || rng.random() < 0.7) return pool[0];
- return rng.choice(pool);
-}
-
-/**
- * `maxSteps` caps the walk by iteration count and, when set, drives the
- * temperature schedule by `step / maxSteps` instead of elapsed time. With a
- * fixed `Rng` and a `timeBudgetMs` generous enough that the deadline never
- * fires first, the walk is then fully deterministic: same seed, same result,
- * bit for bit. That is what the golden fixtures stand on. The solve pipeline
- * itself never sets it — wall time is the right budget in production, where
- * per-step cost varies by island.
- */
-async function hillClimb(
- seed: Placement,
- ctx: IslandContext,
- reactors: EffectiveBuilding[],
- generators: EffectiveBuilding[],
- coolers: EffectiveBuilding[],
- directProducers: EffectiveBuilding[],
- timeBudgetMs: number,
- rng: Rng,
- pacer: Pacer,
- maxSteps?: number,
- collector?: AlternateCollector,
-): Promise {
- if (timeBudgetMs <= 0 || ctx.n === 0) return seed;
-
- const current = seed.slice();
- let sim = simulateIsland(current, ctx);
- let currentPower = sim.totalPower;
- let currentPlacements = sim.placements;
-
- // The walk itself is free to pass through layouts containing overheating
- // buildings — they are often one move away from good ones — but only a STABLE
- // layout may be recorded as the best. Raw power alone would happily settle on
- // a layout that parks heat in a permanently offline generator, which then has
- // to be dismantled at the end, ending up worse than the best stable layout
- // the search already walked past.
- const initial = stabilize(current, ctx);
- let bestPlacement = initial.placement;
- let bestPower = initial.power;
- // The layout the walk starts from is itself an answer at this power, and the
- // shortlist has to hold one before it can recognise a tie with it.
- collector?.offer(initial.power, initial.placement);
- // Highest raw score seen so far, used only to decide when an unstable layout
- // is promising enough to be worth stabilizing and re-scoring.
- let bestRawPower = currentPower;
- let stall = 0;
-
- const recordIfBest = (
- power: number,
- snapshot: Placement,
- rows: SimPlacedBuilding[],
- ): void => {
- if (power <= bestPower + EPS) {
- // Not an improvement — but a layout that *ties* the best is a second
- // answer worth offering the user, so it goes on the shortlist. The
- // checks are ordered by what they cost: `full` is a size comparison,
- // `accepts` reads the layout's tiles, and only then does the stability
- // test walk every row. A converged walk ties its best constantly and
- // nearly all of those are near copies of a layout already held, so
- // `accepts` is what keeps this off the hot path once the shortlist has
- // stopped growing.
- if (
- collector !== undefined &&
- !collector.full &&
- powerTies(power, collector.power) &&
- collector.accepts(snapshot) &&
- !anyOfflineProducer(snapshot, rows) &&
- planHolds(snapshot, ctx)
- ) {
- collector.offer(power, snapshot);
- }
- return;
- }
-
- if (!anyOfflineProducer(snapshot, rows) && planHolds(snapshot, ctx)) {
- bestPower = power;
- bestPlacement = snapshot.slice();
- collector?.offer(power, snapshot);
- stall = 0;
- return;
- }
-
- // Unstable. Only worth the extra simulations when it is a new raw
- // high-water mark — otherwise the walk would re-stabilize the same
- // unreachable peak on every visit.
- if (power > bestRawPower + EPS) {
- bestRawPower = power;
- const stabilized = stabilize(snapshot, ctx);
- if (stabilized.power > bestPower + EPS) {
- bestPower = stabilized.power;
- bestPlacement = stabilized.placement;
- collector?.offer(stabilized.power, stabilized.placement);
- stall = 0;
- }
- }
- };
-
- const topCooler = coolers.length > 0 ? coolers[0] : null;
- const topGenerator = generators.length > 0 ? generators[0] : null;
- const pools = [reactors, generators, coolers, directProducers].filter(
- (p) => p.length > 0,
- );
-
- const startMs = performance.now();
- const deadlineMs = startMs + timeBudgetMs;
-
- // Temperature is scaled to the size of one MOVE, not to the island's total
- // output. Changing a single tile is worth roughly one generator's power
- // wherever it happens, so scaling by total power made large islands run far
- // too hot: on the 67-tile map 1 island, ~23% of moves that each cost 7% of
- // the layout were being accepted, and the walk never climbed back.
- //
- // It is read off the PLAIN roster, so under a rule that rates a tile above it
- // the walk runs colder than this calibration intends — a move on a Tidal
- // shore is worth its `multiplier` times one here. Scaling it by
- // `islandRatingCeiling` was measured: Tidal (the pre-nerf
- // `tidal_ascendancy_legacy`) on map 3 at 15s over three seeds went
- // 176/178/179AC to 179/181/178AC, and Singularity at 30s over five seeds on
- // map 3 142/140/142/142/140AC to
- // 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to
- // 267/271/271/271/274AC — inside run-to-run noise, so the plain figure is kept.
- // Anyone re-measuring should use a longer budget and more seeds than that.
- const moveScale =
- generators.length > 0
- ? generators[0].effectiveValue * generatorEnergyRatio(generators[0])
- : 1.0;
- const tStart = Math.max(moveScale * 0.1, 1.0);
- const tMin = 1e-4;
- let temperature = tStart;
-
- // Snap back to the best layout after a long run of no improvement. Without
- // this, one unlucky sequence of accepted downhill moves strands the walk in a
- // region it cannot climb out of for the rest of the budget.
- const stallLimit = Math.max(2000, ctx.n * 200);
-
- let step = 0;
- const batchMask = 127;
-
- /** Accepts or rolls back a trial move, Metropolis-style. */
- const accept = (power: number, rows: SimPlacedBuilding[]): boolean => {
- const delta = power - currentPower;
- const exponent = temperature > 0 ? delta / temperature : -100.0;
- if (
- delta > EPS ||
- (exponent > -50.0 && rng.random() < Math.exp(exponent))
- ) {
- currentPower = power;
- currentPlacements = rows;
- recordIfBest(power, current, rows);
- return true;
- }
- return false;
- };
-
- for (;;) {
- if (maxSteps !== undefined && step >= maxSteps) break;
-
- if ((step & batchMask) === 0) {
- const now = performance.now();
- if (now >= deadlineMs) break;
-
- if (pacer.due(now)) {
- await pacer.pump(now, () => islandProgress(bestPlacement, ctx));
- if (pacer.stopRequested) break;
- }
-
- const progress =
- maxSteps !== undefined
- ? Math.min(1.0, step / maxSteps)
- : Math.min(1.0, Math.max(0.0, (now - startMs) / timeBudgetMs));
- temperature = tStart * Math.pow(tMin / tStart, progress);
-
- if (stall >= stallLimit) {
- copyInto(current, bestPlacement);
- sim = simulateIsland(current, ctx);
- currentPower = sim.totalPower;
- currentPlacements = sim.placements;
- stall = 0;
- }
- }
-
- step += 1;
- stall += 1;
- const moveType = rng.random();
-
- if (moveType < 0.15 && currentPlacements.length > 0) {
- const uncooled: SimPlacedBuilding[] = [];
- const wastedReactors: SimPlacedBuilding[] = [];
- for (const p of currentPlacements) {
- if (
- p.wasteHeatGenerated > EPS &&
- !wasteIsCovered(p.wasteHeatGenerated, p.coolingReceived)
- ) {
- uncooled.push(p);
- }
- if (isUnderFed(p)) {
- wastedReactors.push(p);
- }
- }
-
- if (uncooled.length > 0 && topCooler !== null && rng.random() < 0.6) {
- const target = rng.choice(uncooled);
- const adj = ctx.neighbors[target.idx];
- // Beside the starved producer, which is the only place a cooler can
- // reach it. Under a pool any tile would do and a short pool starves
- // every producer at once, so this writes over a random occupied
- // neighbour there — often the reactor feeding the very producer it
- // means to save. Aiming it at an empty tile instead was measured on
- // maps 3 and 7 at 15s over three seeds and changed nothing, so the one
- // shape is kept.
- if (adj.length > 0) {
- const tile = adj[rng.int(adj.length)];
- const old = current[tile];
- // In the tile's units, or the guard never fires on a rated tile —
- // see `ratedFor`.
- if (ratedFor(ctx, tile, topCooler) !== old) {
- put(current, ctx, tile, topCooler);
- const trial = simulateIsland(current, ctx);
- if (!accept(trial.totalPower, trial.placements))
- put(current, ctx, tile, old);
- continue;
- }
- }
- } else if (
- wastedReactors.length > 0 &&
- topGenerator !== null &&
- rng.random() < 0.6
- ) {
- const target = rng.choice(wastedReactors);
- const adj = ctx.neighbors[target.idx];
- if (adj.length > 0) {
- const tile = adj[rng.int(adj.length)];
- const old = current[tile];
- if (ratedFor(ctx, tile, topGenerator) !== old) {
- put(current, ctx, tile, topGenerator);
- const trial = simulateIsland(current, ctx);
- if (!accept(trial.totalPower, trial.placements))
- put(current, ctx, tile, old);
- continue;
- }
- }
- }
- }
-
- if (moveType < 0.5 && ctx.n >= 2) {
- const tile1 = rng.int(ctx.n);
- const adj = ctx.neighbors[tile1];
- let tile2: number;
- if (adj.length > 0 && rng.random() < 0.7) {
- tile2 = adj[rng.int(adj.length)];
- } else {
- tile2 = rng.int(ctx.n);
- while (tile2 === tile1) tile2 = rng.int(ctx.n);
- }
-
- const val1 = current[tile1];
- const val2 = current[tile2];
- // A swap that writes each tile what it already holds. On one scale class
- // that is `val1 === val2`; across two it is not, because the same roster
- // entry is a different object on each — so the test is on what the writes
- // would land, which covers both.
- if (
- ratedFor(ctx, tile1, val2) === val1 &&
- ratedFor(ctx, tile2, val1) === val2
- ) {
- continue;
- }
-
- put(current, ctx, tile1, val2);
- put(current, ctx, tile2, val1);
-
- const trial = simulateIsland(current, ctx);
- if (!accept(trial.totalPower, trial.placements)) {
- put(current, ctx, tile1, val1);
- put(current, ctx, tile2, val2);
- }
- continue;
- }
-
- const tile = rng.int(ctx.n);
- const old = current[tile];
- const replacement = randomCandidate(pools, rng);
- // The commonest move in the walk, and the commonest no-op: `randomCandidate`
- // returns the top tier of a pool with p ~ 0.7, so a packed island proposes
- // the building a tile already holds constantly.
- if (ratedFor(ctx, tile, replacement) === old) continue;
-
- put(current, ctx, tile, replacement);
- const trial = simulateIsland(current, ctx);
- if (!accept(trial.totalPower, trial.placements)) current[tile] = old;
- }
-
- return bestPlacement;
-}
-
-// ---------------------------------------------------------------------------
-// Composition retargeting
-// ---------------------------------------------------------------------------
-
-interface ScoredComposition {
- power: number;
- composition: EffectiveBuilding[];
-}
-
-/**
- * The best MULTISETS of buildings for an island of this size, ignoring where
- * they go — highest theoretical power first.
- *
- * Which buildings to use is a tiny counting problem, not a search problem. For
- * counts (nReactor, nGenerator, nCooler) summing to the tile budget, the layout
- * can convert at most `min(reactor output, nGenerator * H_max, nCooler * C /
- * WASTE_RATIO)` of heat, so enumerating the O(tiles^2) splits and greedily
- * filling the reactor tiles with the largest tiers that stay inside that budget
- * gives the exact best composition. On the 67-tile map 1 island this returns
- * 13 neuro + 1 sauron_eye + 14 generator6 + 39 cooler6 — the known optimum — in
- * a few milliseconds, where the search alone plateaued 1.6% below it.
- *
- * Placement can still make a composition unachievable (a generator can only be
- * fed by ADJACENT reactors), which is why several are returned, each with the
- * power it would reach if it could be arranged perfectly: the caller tries them
- * in order, and can skip any that could not beat what it already has.
- */
-function targetCompositions(
- tileCount: number,
- reactors: EffectiveBuilding[],
- generators: EffectiveBuilding[],
- coolers: EffectiveBuilding[],
- generatorCapacity: Float64Array | null,
- topK = COMPOSITION_TARGETS,
-): ScoredComposition[] {
- if (
- reactors.length === 0 ||
- generators.length === 0 ||
- coolers.length === 0 ||
- tileCount < 3
- ) {
- return [];
- }
-
- const generator = generators[0];
- const cooler = coolers[0];
- const scored: ScoredComposition[] = [];
-
- for (let nGen = 1; nGen < tileCount - 1; nGen++) {
- for (let nCool = 1; nCool < tileCount - nGen; nCool++) {
- const nReact = tileCount - nGen - nCool;
- if (nReact < 1) continue;
-
- // Most heat this many generators, and this much cooling, can carry.
- const wasteRatio = generatorWasteRatio(generator);
- if (wasteRatio <= 0) continue;
- const budget = Math.min(
- generatorCapacity === null
- ? nGen * generator.effectiveValue
- : generatorCapacity[nGen],
- (nCool * cooler.effectiveValue) / wasteRatio,
- );
-
- // Two ways to fill the reactor tiles, and which one wins depends on what
- // is actually binding.
- //
- // Packing them with the largest tier maximizes heat. Overshooting the
- // budget costs nothing — surplus heat is simply never absorbed and power
- // stays capped — so this is what to do when generator or cooling capacity
- // is the limit.
- //
- // Stepping down through tiers to land just under the budget instead
- // matters when COOLING is the limit: the all-or-nothing rule shuts a
- // generator down entirely if its waste outruns the cooling reaching it,
- // so a smaller top-up reactor can be worth more than a larger one.
- const fills: EffectiveBuilding[][] = [
- new Array(nReact).fill(reactors[0]),
- ];
-
- let heat = 0.0;
- const mix: EffectiveBuilding[] = [];
- for (const tier of reactors.slice(0, POLISH_REACTOR_TIERS)) {
- if (tier.effectiveValue <= 0) continue;
- const take = Math.min(
- nReact - mix.length,
- Math.floor((budget - heat) / tier.effectiveValue),
- );
- if (take > 0) {
- heat += take * tier.effectiveValue;
- for (let i = 0; i < take; i++) mix.push(tier);
- }
- }
- if (mix.length === nReact && !sameFill(mix, fills[0])) fills.push(mix);
-
- for (const fill of fills) {
- let fillHeat = 0.0;
- for (const b of fill) fillHeat += b.effectiveValue;
- const absorbed = Math.min(fillHeat, budget);
- const power = absorbed * generatorEnergyRatio(generator);
- if (power > EPS) {
- const composition = fill.slice();
- for (let i = 0; i < nGen; i++) composition.push(generator);
- for (let i = 0; i < nCool; i++) composition.push(cooler);
- scored.push({ power, composition });
- }
- }
- }
- }
-
- // Stable sort, so equal-scoring compositions keep the order they were
- // enumerated in and the walk is reproducible.
- scored.sort((a, b) => b.power - a.power);
- return scored.slice(0, topK);
-}
-
-/**
- * How much heat `nGen` generator tiles on THIS island can take in, or null
- * under every rule that leaves the count alone.
- *
- * `targetCompositions` decides what to build by counting, and the count it
- * wants is a function of what a generator is worth. Under a role isolation
- * that is two numbers rather than one — a generator with no generator beside
- * it takes 4x, one with 0.8x — and which a tile gets is decided by the
- * layout, so neither belongs in the pool the stage draws on. The roster cannot
- * express it; the island can, and `isolationRoom` is how much of the bonus its
- * shape has room for.
- *
- * Both halves are load-bearing. Sizing at the bonus alone asks Gale Hills at
- * generator7 tier 1 for 20 generators on an island that can keep 15 apart, and
- * `arrangeComposition` cannot spread that many without most of them touching.
- * With the room folded in it asks for 16, and over ten runs at 15s the mean
- * goes 44.6AC to 46.2AC; the best either reaches is 49.8AC, fifteen generators
- * every one isolated. Over three seeds it is worth +12-14% on maps 7 and 8 and
- * costs 1-4% on maps 2, 5 and 6 — a net gain, not a uniform one.
- *
- * The same table the bound runs on (`island.ts`), and deliberately so — one
- * reading of what a board has room for, rather than a second one here to drift
- * against it. It is built from the island's own ratings rather than the
- * capped figures the bound uses: a bound may relax adjacency away, a target
- * has to be something the board can hold.
- */
-function generatorCapacityTable(
- island: IslandSubGrid,
- ctx: IslandContext,
- generator: EffectiveBuilding | undefined,
-): Float64Array | null {
- if (generator === undefined) return null;
-
- const isolated = ctx.rateIsolated(generator, false).effectiveValue;
- const crowded = ctx.rateIsolated(generator, true).effectiveValue;
- // Equal under every other rule, and under one that rates a crowded building
- // no lower there is no ceiling to respect: the count is linear again.
- if (crowded >= isolated) return null;
-
- return generatorCapacities(ctx.n, isolated, {
- gTake: crowded,
- room: isolationRoom(island),
- });
-}
-
-/**
- * The largest factor anything on this island can be rated at — 1 under the rules
- * that leave the roster alone.
- *
- * `targetCompositions` scores a target from the plain roster, and it is right to:
- * which buildings to use is a counting problem over the roster, and the counting
- * is the same whatever the tiles are worth. But the layout the caller compares it
- * against has been rated tile by tile, so the two figures are in different units,
- * and a target's ceiling read as-is says "this cannot beat what I have" about
- * every target on the list. The gate is a `break`, so the whole stage goes with
- * it — silently, and hardest under the anomaly that most needs it: on island3
- * under Tidal a 0.6s run already returns 1.68e23 against a top target of
- * 1.49e23, so the stage never ran at any realistic budget.
- *
- * Scaling the target by one island-wide factor is the move `estimateTotalMaxPower`
- * makes to keep its bound a bound wherever its mask cannot say which tiles carry
- * the factor (`islandMaxScale`), and it is sound for the same reason: a
- * composition's power is positively homogeneous of degree 1 in
- * the roster's figures, so rating the whole island at its best tile is an upper
- * bound on rating each tile at its own. Loose where only part of an island
- * qualifies, which is the right way to be wrong here — a ceiling that is too low
- * skips a stage that would have helped, while one that is too high costs a few
- * arrangement attempts that fail to beat the layout in hand.
- *
- * It is asked of the context rather than read off the anomaly so that a rule
- * resolved per tile (`rate`) and one resolved per layout (`rateIsolated`) are
- * both answered by the code that actually applies them, and a fifth rule shape
- * needs nothing here at all.
- */
-function islandRatingCeiling(
- ctx: IslandContext,
- probes: readonly EffectiveBuilding[],
- sizedByIsolation: boolean,
-): number {
- if (ctx.uniformRating && (ctx.isolation === null || sizedByIsolation))
- return 1;
-
- let ceiling = 1;
- const consider = (factor: number): void => {
- if (factor > ceiling) ceiling = factor;
- };
-
- for (const probe of probes) {
- if (probe.effectiveValue <= 0) continue;
- if (!ctx.uniformRating) {
- for (let tile = 0; tile < ctx.n; tile++) {
- consider(ctx.rate(tile, probe).effectiveValue / probe.effectiveValue);
- }
- }
- if (ctx.isolation !== null && !sizedByIsolation) {
- // Both variants, because which one a tile gets is a function of the
- // layout: a search free to keep generators apart rates every one of them
- // at the bonus. The same reasoning `islandMaxScale` carries.
- consider(
- ctx.rateIsolated(probe, false).effectiveValue / probe.effectiveValue,
- );
- consider(
- ctx.rateIsolated(probe, true).effectiveValue / probe.effectiveValue,
- );
- }
- }
- return ceiling;
-}
-
-/**
- * Whether a composition target could still beat the layout in hand, which is what
- * decides whether the retarget stage runs at all.
- *
- * `target.power` is the power a perfect arrangement of the target would reach at
- * the PLAIN roster's figures and `bestPower` is what a rated layout actually
- * scored, so the ceiling is what puts the two in the same units — see
- * `islandRatingCeiling`. The caller's gate is a `break` over a descending list,
- * so answering `false` once ends the stage: a named function so the reading can
- * be pinned, since it is the difference between a stage that runs and one that is
- * silently never entered.
- */
-function targetCanBeat(
- target: ScoredComposition,
- bestPower: number,
- ratingCeiling: number,
-): boolean {
- return target.power * ratingCeiling > bestPower + EPS;
-}
-
-function sameFill(a: EffectiveBuilding[], b: EffectiveBuilding[]): boolean {
- if (a.length !== b.length) return false;
- for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
- return true;
-}
-
-function countById(
- buildings: Iterable,
-): Map {
- const counts = new Map();
- for (const b of buildings) counts.set(b.id, (counts.get(b.id) ?? 0) + 1);
- return counts;
-}
-
-function countPlacement(placement: Placement): Map {
- const counts = new Map();
- for (const b of placement) {
- if (b !== null) counts.set(b.id, (counts.get(b.id) ?? 0) + 1);
- }
- return counts;
-}
-
-function sameCounts(a: Map, b: Map): boolean {
- if (a.size !== b.size) return false;
- for (const [id, n] of a) if (b.get(id) !== n) return false;
- return true;
-}
-
-/**
- * Places a fixed multiset of buildings, then improves the ARRANGEMENT only.
- *
- * Starting from `reference` (the best layout found so far), tiles are converted
- * to match the target counts, choosing at each step the change that costs least
- * power. The result is then improved with swaps, which move buildings around
- * without changing what is on the board — so every candidate keeps the target's
- * global heat/cooling budget and the search is over arrangements alone.
- */
-async function arrangeComposition(
- composition: EffectiveBuilding[],
- reference: Placement,
- ctx: IslandContext,
- deadlineMs: number,
- pacer: Pacer,
-): Promise {
- if (composition.length > ctx.n) return null;
-
- const wanted = countById(composition);
- const byId = new Map();
- for (const b of composition) byId.set(b.id, b);
- const current = reference.slice();
-
- // 1. Reconcile counts with the target, cheapest change first.
- while (performance.now() < deadlineMs && !pacer.stopRequested) {
- const have = countPlacement(current);
- const surplus = new Set();
- for (const [id, n] of have) {
- if (n > (wanted.get(id) ?? 0)) surplus.add(id);
- }
-
- let targetId: string | null = null;
- for (const [id, n] of wanted) {
- if (n > (have.get(id) ?? 0)) {
- targetId = id;
- break;
- }
- }
-
- let bestMove = -1;
- let bestPower = -1.0;
-
- if (targetId === null) {
- // Too many buildings overall: drop the least useful surplus tile.
- if (surplus.size === 0) break;
- for (let tile = 0; tile < ctx.n; tile++) {
- const held = current[tile];
- if (held === null || !surplus.has(held.id)) continue;
- current[tile] = null;
- const power = simulateIsland(current, ctx).totalPower;
- put(current, ctx, tile, held);
- if (power > bestPower) {
- bestPower = power;
- bestMove = tile;
- }
- }
- if (bestMove < 0) break;
- current[bestMove] = null;
- } else {
- const building = byId.get(targetId)!;
- for (let tile = 0; tile < ctx.n; tile++) {
- if (current[tile] !== null) continue;
- put(current, ctx, tile, building);
- const power = simulateIsland(current, ctx).totalPower;
- current[tile] = null;
- if (power > bestPower) {
- bestPower = power;
- bestMove = tile;
- }
- }
- if (bestMove < 0) {
- for (let tile = 0; tile < ctx.n; tile++) {
- const held = current[tile];
- if (held === null || !surplus.has(held.id)) continue;
- put(current, ctx, tile, building);
- const power = simulateIsland(current, ctx).totalPower;
- put(current, ctx, tile, held);
- if (power > bestPower) {
- bestPower = power;
- bestMove = tile;
- }
- }
- }
- if (bestMove < 0) return null;
- put(current, ctx, bestMove, building);
- }
-
- const now = performance.now();
- if (pacer.due(now)) await pacer.pump(now);
- }
-
- if (!sameCounts(countPlacement(current), wanted)) return null;
-
- // 2. Improve the arrangement with swaps, which preserve the composition.
- // Only nearby pairs are tried: swapping two buildings changes the layout
- // solely through who they are adjacent to, so a swap between opposite ends of
- // the island is two independent local changes and is better found as two
- // separate moves. Restricting to Chebyshev distance 2 cuts the pairs to a
- // fraction of all-pairs on a large island.
- const occupied: number[] = [];
- for (let tile = 0; tile < ctx.n; tile++) {
- if (current[tile] !== null) occupied.push(tile);
- }
- // Plain ascending (x, y) order, which is NOT the tile index order
- // (ascending X, DESCENDING Y). The fixtures pin this pairing exactly.
- occupied.sort((a, b) => ctx.xs[a] - ctx.xs[b] || ctx.ys[a] - ctx.ys[b]);
-
- const pairs: [number, number][] = [];
- for (let i = 0; i < occupied.length; i++) {
- for (let j = i + 1; j < occupied.length; j++) {
- if (chebyshev(ctx, occupied[i], occupied[j]) <= SWAP_RADIUS) {
- pairs.push([occupied[i], occupied[j]]);
- }
- }
- }
-
- while (performance.now() < deadlineMs && !pacer.stopRequested) {
- const basePower = simulateIsland(current, ctx).totalPower;
- let bestGain = 0.0;
- let bestSwap: [number, number] | null = null;
-
- for (let index = 0; index < pairs.length; index++) {
- if ((index & 63) === 0) {
- const now = performance.now();
- if (now >= deadlineMs) break;
- if (pacer.due(now)) {
- await pacer.pump(now);
- if (pacer.stopRequested) break;
- }
- }
-
- const [a, b] = pairs[index];
- const valA = current[a];
- const valB = current[b];
- // Swapping identical buildings changes nothing — asked in each tile's own
- // units, so two scale classes holding the same roster entry still count
- // as identical. See `ratedFor`.
- if (ratedFor(ctx, a, valB) === valA && ratedFor(ctx, b, valA) === valB) {
- continue;
- }
-
- put(current, ctx, a, valB);
- put(current, ctx, b, valA);
-
- const trial = simulateIsland(current, ctx);
- if (
- trial.totalPower - basePower > bestGain &&
- !anyOfflineProducer(current, trial.placements) &&
- planHolds(current, ctx)
- ) {
- bestGain = trial.totalPower - basePower;
- bestSwap = [a, b];
- }
-
- put(current, ctx, a, valA);
- put(current, ctx, b, valB);
- }
-
- if (bestSwap === null) break;
- const [a, b] = bestSwap;
- const held = current[a];
- put(current, ctx, a, current[b]);
- put(current, ctx, b, held);
- }
-
- return current;
-}
-
-// ---------------------------------------------------------------------------
-// Local repair and pruning
-// ---------------------------------------------------------------------------
-
-/**
- * The lean set of buildings worth trying on a tile during local repair.
- *
- * Generators and coolers jump 400x-4100x per tier, so only their top tier can
- * ever appear in a good layout. Reactors only jump ~8x, which is small enough
- * that a weaker one is genuinely useful for topping up a generator's spare heat
- * capacity when there is leftover cooling, so a few reactor tiers are kept.
- * Every extra candidate costs another full pass over the island.
- */
-function polishCandidates(
- reactors: EffectiveBuilding[],
- generators: EffectiveBuilding[],
- coolers: EffectiveBuilding[],
- directProducers: EffectiveBuilding[],
-): (EffectiveBuilding | null)[] {
- const candidates: (EffectiveBuilding | null)[] = reactors.slice(
- 0,
- POLISH_REACTOR_TIERS,
- );
- if (generators.length > 0) candidates.push(generators[0]);
- if (coolers.length > 0) candidates.push(coolers[0]);
- if (directProducers.length > 0) candidates.push(directProducers[0]);
- // Leaving the tile empty is a real option.
- candidates.push(null);
- return candidates;
-}
-
-/**
- * Steepest-ascent repair: repeatedly applies the single-tile change that raises
- * power the most, considering only stable layouts, until no single change
- * helps.
- *
- * The annealing walk handles small islands well, but on a large, fully packed
- * one nearly every single-tile change costs several percent, so the walk drifts
- * downhill and rarely climbs back: measured on a 67-tile island, only 1
- * evaluation in 26,000 beat its own starting layout, while 20 stable improving
- * moves sat a single move away. A full sweep here costs |tiles| x |candidates|
- * simulations and finds them directly.
- */
-async function greedyPolish(
- placement: Placement,
- ctx: IslandContext,
- candidates: (EffectiveBuilding | null)[],
- deadlineMs: number,
- pacer: Pacer,
-): Promise<{ placement: Placement; power: number }> {
- let current = placement.slice();
- const sim = simulateIsland(current, ctx);
- let bestPower = sim.totalPower;
- if (anyOfflineProducer(current, sim.placements) || !planHolds(current, ctx)) {
- const stabilized = stabilize(current, ctx);
- current = stabilized.placement;
- bestPower = stabilized.power;
- }
-
- while (performance.now() < deadlineMs && !pacer.stopRequested) {
- let bestGain = 0.0;
- let bestTile = -1;
- let bestBuilding: EffectiveBuilding | null = null;
-
- for (let tile = 0; tile < ctx.n; tile++) {
- const now = performance.now();
- if (now >= deadlineMs) break;
- if (pacer.due(now)) {
- await pacer.pump(now, () => islandProgress(current, ctx));
- if (pacer.stopRequested) break;
- }
-
- const held = current[tile];
- for (const building of candidates) {
- // The candidate list is the plain roster, `held` is rated for this tile:
- // asked the wrong way round, a scaled tile re-simulates its own layout
- // once per sweep for nothing. See `ratedFor`.
- if (ratedFor(ctx, tile, building) === held) continue;
-
- put(current, ctx, tile, building);
- const trial = simulateIsland(current, ctx);
- if (
- trial.totalPower - bestPower > bestGain &&
- !anyOfflineProducer(current, trial.placements) &&
- planHolds(current, ctx)
- ) {
- bestGain = trial.totalPower - bestPower;
- bestTile = tile;
- bestBuilding = building;
- }
- put(current, ctx, tile, held);
- }
- }
-
- if (bestTile < 0) break;
- put(current, ctx, bestTile, bestBuilding);
- bestPower += bestGain;
- }
-
- return { placement: current, power: bestPower };
-}
-
-/**
- * Post-search cleanup:
- * 1. Drops every producer generating 0 power, repeating until none remain.
- * 2. Sequentially tests removing reactors/coolers to eliminate redundant
- * support structures.
- * 3. Guarantees a stable layout — every returned producer is online — with a
- * minimal active tile footprint.
- *
- * Step 1 is unconditional: an overheating building is never acceptable, even
- * when keeping it would score higher (see `stabilize`). Step 2 is guarded,
- * since removing support can only ever cost power.
- */
-function pruneDeadWeight(
- placement: Placement,
- ctx: IslandContext,
-): { rows: SimPlacedBuilding[]; power: number } {
- let anyOccupied = false;
- for (let tile = 0; tile < ctx.n; tile++) {
- if (placement[tile] !== null) {
- anyOccupied = true;
- break;
- }
- }
- if (!anyOccupied) return { rows: [], power: 0.0 };
-
- // 1. Remove producers that generate no power (cascading).
- const stabilized = stabilize(placement, ctx);
- const current = stabilized.placement;
- let bestPower = stabilized.power;
- let rows = stabilized.rows;
-
- // 2. Greedy backward sweep: test removing support buildings (reactors & coolers).
- const support: number[] = [];
- for (let tile = 0; tile < ctx.n; tile++) {
- const b = current[tile];
- if (b === null) continue;
- if (b.type === "cooler" || isReactor(b)) support.push(tile);
- }
-
- for (const tile of support) {
- const held = current[tile];
- if (held === null) continue;
-
- current[tile] = null;
- const trial = simulateIsland(current, ctx);
-
- // Power alone is not enough to accept a removal. Dropping a reactor can
- // starve one generator to zero while handing its heat to another that had
- // spare capacity, leaving total power unchanged but stranding an idle
- // generator on the board.
- if (
- trial.totalPower >= bestPower - EPS &&
- !anyOfflineProducer(current, trial.placements) &&
- planHolds(current, ctx)
- ) {
- bestPower = trial.totalPower;
- rows = trial.placements;
- } else {
- put(current, ctx, tile, held);
- }
- }
-
- return { rows, power: bestPower };
-}
-
-// ---------------------------------------------------------------------------
-// Right-sizing the finished layout
-// ---------------------------------------------------------------------------
-
-/**
- * The roles a finished layout can be re-tiered in. A direct producer always
- * runs flat out on its own, so there is no slack in one to give back — a
- * smaller tier is simply less power, and the power guard below would reject it
- * anyway. The other three are sized against a load the finished layout fixes,
- * and any capacity above that load is money spent on a building that never
- * uses it.
- */
-const DOWNGRADE_ROLES: ReadonlySet = new Set([
- "cooler",
- "reactor",
- "generator",
-]);
+import { AlternateCollector, isDistinctLayout, powerTies } from "./alternates";
+import { EPS } from "./constants";
+import { buildIslandContext, type IslandContext } from "./context";
+import { Pacer } from "./pacer";
+import { randomSeed, Rng } from "./rng";
+import type {
+ AnomalyDefinition,
+ EffectiveBuilding,
+ IslandLayout,
+ IslandSubGrid,
+ PlacedBuilding,
+ SearchHooks,
+} from "./types";
+import { hillClimb } from "./search/anneal";
+import {
+ arrangeComposition,
+ generatorCapacityTable,
+ islandRatingCeiling,
+ targetCanBeat,
+ targetCompositions,
+} from "./search/composition";
+import {
+ emptyPlacement,
+ isDirectProducer,
+ isReactor,
+ put,
+ toPlacedBuildings,
+} from "./search/placement";
+import {
+ EVACUATE_BUDGET_MS,
+ GATHER_BUDGET_MS,
+ gatherPooledCoolers,
+} from "./search/pooledCoolers";
+import {
+ greedyPolish,
+ polishCandidates,
+ pruneDeadWeight,
+} from "./search/repair";
+import {
+ DOWNGRADE_ROLES,
+ downgradeOversized,
+ downgradeTiers,
+ tileLoad,
+} from "./search/rightSize";
+import { constructMultiStartSeed } from "./search/seed";
+import {
+ isUnderFed,
+ offlineProducers,
+ stabilize,
+ upgradePlans,
+} from "./search/stability";
-/**
- * True when `capacity` meets `load` at the game's own relative tolerance.
- *
- * This is `wasteIsCovered`, whose parameters are named for the cooling case it
- * was written for; the tolerance has to be relative here for the same reason it
- * does there — at 1e18 one ULP is already ~1e2, so an absolute epsilon would
- * reject a tier that is exactly big enough.
- */
-function covers(capacity: number, load: number): boolean {
- return wasteIsCovered(load, capacity);
-}
+// The stages live in `./search/`, one file each; this module is still the one
+// entry point, so what it exported before the split it exports now.
+export { downgradeOversized } from "./search/rightSize";
/**
- * What a placed building is actually doing, in the units its tier is sized in.
+ * Placement search for a single island:
*
- * Cooling sent for a cooler, heat sent for a reactor, heat absorbed for a
- * generator — the figure a replacement tier has to be able to match for the
- * rest of the layout to keep behaving the same way.
- */
-function tileLoad(building: EffectiveBuilding, row: SimPlacedBuilding): number {
- if (building.type === "cooler") return row.coolingProvided;
- if (isReactor(building)) return row.heatProduced;
- return row.heatConsumed;
-}
-
-/**
- * The capacity `candidate` would actually run at on `tile` in this layout —
- * `tileLoad`'s units, so the two can be compared.
+ * seed -> repair -> composition retarget -> (annealing -> repair)* -> pruning
+ * -> right-sizing
*
- * `ctx.rate` answers this for every rule but one: a role isolation multiplier is
- * a function of what a tile's NEIGHBOURS are, so `rate` is the identity under it
- * and `simulateIsland` resolves it per layout through `rateIsolated` instead.
- * That leaves right-sizing measuring a load rated by `isolated` against an
- * unrated capacity: an isolated generator authored 320, rated 1280 (at the
- * `isolated` the rule shipped with) and absorbing 300, was never offered the
- * authored 120 tier that covers it at its own rating of 480, so the pass left
- * 980 of intake nobody pays it to have — which is exactly the money it
- * exists to hand back. In the other direction the plain figure is over-generous,
- * and there the re-simulation below catches it, so only the waste escaped.
+ * The `annealing -> repair` pair repeats until the deadline. Repair's slice is
+ * reserved before the layout it will run on exists, and on a post-anneal layout
+ * it almost always converges in one fruitless sweep, so whatever it hands back
+ * becomes another short walk rather than idle time at the end of the budget.
*
- * The neighbour scan mirrors `simulateIsland`'s: it reads what a neighbour *is*,
- * which the swap cannot change — the ladder is one role deep, so a re-tiered
- * generator is still a generator and every tile's crowding survives the pass.
- */
-function ratedCapacity(
- ctx: IslandContext,
- placement: Placement,
- tile: number,
- candidate: EffectiveBuilding,
-): number {
- const isolation = ctx.isolation;
- if (isolation === null || candidate.type !== isolation.role) {
- return candidate.effectiveValue;
- }
-
- const neighbors = ctx.neighbors[tile];
- let crowded = false;
- for (let k = 0; k < neighbors.length; k++) {
- const other = placement[neighbors[k]];
- if (other !== null && other.type === isolation.role) {
- crowded = true;
- break;
- }
- }
- return ctx.rateIsolated(candidate, crowded).effectiveValue;
-}
-
-/** Each downgradable role's roster entries, smallest capacity first. */
-function downgradeTiers(
- effectiveBuildings: EffectiveBuilding[],
-): Map {
- const tiers = new Map();
- for (const role of DOWNGRADE_ROLES) {
- tiers.set(
- role,
- effectiveBuildings
- .filter((b) => b.type === role)
- .sort((a, b) => a.effectiveValue - b.effectiveValue),
- );
- }
- return tiers;
-}
-
-/**
- * Final pass: replace every building with the smallest tier that still does the
- * job the finished layout gives it. Port of `_downgrade_oversized`.
+ * Seed construction lays out self-sufficient "hubs" — one generator plus
+ * exactly the reactors and coolers it needs on its own. That is a good starting
+ * shape but a systematically incomplete one: real layouts do better by letting
+ * neighbouring hubs SHARE, one cooler absorbing waste from two generators or
+ * one reactor feeding two, which frees tiles for more producers. Nothing in
+ * seed construction can express that, and on the 67-tile map 1 island it caps
+ * out 7% below the best known layout.
*
- * The search maximizes power, and power cannot tell a cooler running flat out
- * from one running at 2%: both keep the same generators online, so both score
- * identically and the search has no reason to prefer either. It therefore
- * leaves the roster's top tier on tiles that need a twentieth of it, which
- * costs the player real money for capacity that never runs. This is why the
- * pass belongs after the search rather than inside it — only once the layout
- * stops moving is the load on each tile settled enough to size against.
+ * The two stages around it exist to cover that blind spot from different sides:
*
- * Covering the tile's current load is what makes a candidate plausible, not
- * what makes it safe: shrinking a supplier changes the FairShare split, and the
- * split is what decides which producers clear their cooling. So every swap is
- * re-simulated and accepted only under the test `pruneDeadWeight` applies to a
- * removal — no power lost against the layout handed in, and no producer left
- * offline. Power is defended against the figure this pass started from rather
- * than against the running total, so a sequence of swaps cannot drift down one
- * tolerance at a time.
+ * - `greedyPolish` is steepest ascent over single-tile changes. Annealing alone
+ * was supposed to find these, and does on small islands, but on a large
+ * packed island almost every single-tile change costs several percent, so the
+ * walk drifts downhill and rarely climbs back — measured there, only 1
+ * evaluation in 26,000 beat its own starting layout while 20 stable improving
+ * moves sat a single move away.
+ * - `targetCompositions` / `arrangeComposition` attack the other half. Deciding
+ * WHAT to build is a counting problem with an exact answer, so it is computed
+ * directly rather than searched for; the search is then left with the
+ * genuinely hard part, which is where to put it.
*
- * The sweep repeats to a fixpoint: downgrading a generator changes the waste
- * its cooler has to absorb, which can free a cooler this sweep already walked
- * past. It terminates because every accepted swap strictly lowers one tile's
- * capacity and the ladders are finite.
+ * Annealing keeps its place between them for the moves neither can make: it can
+ * cross a barrier that no single change improves upon.
*
- * Tiles are visited in ascending index order, which is the game's own spatial
- * order — see `context.ts`.
- */
-export function downgradeOversized(
- rows: SimPlacedBuilding[],
- power: number,
- effectiveBuildings: EffectiveBuilding[],
- ctx: IslandContext,
-): { rows: SimPlacedBuilding[]; power: number } {
- if (rows.length === 0) return { rows, power };
-
- const byId = new Map(effectiveBuildings.map((b) => [b.id, b]));
- const current = emptyPlacement(ctx.n);
- const occupied: number[] = [];
- for (const row of rows) {
- const building = byId.get(row.buildingId);
- // Everything the search places comes from this roster; a layout holding
- // anything else is not one this pass can reason about.
- if (building === undefined) return { rows, power };
- put(current, ctx, row.idx, building);
- occupied.push(row.idx);
- }
- occupied.sort((a, b) => a - b);
-
- const tiers = downgradeTiers(effectiveBuildings);
- const rowAt = new Map(rows.map((r) => [r.idx, r]));
- const floor = power;
-
- for (let changed = true; changed;) {
- changed = false;
-
- for (const tile of occupied) {
- const building = current[tile]!;
- const ladder = tiers.get(building.type);
- if (ladder === undefined || ladder.length === 0) continue;
-
- const load = tileLoad(building, rowAt.get(tile)!);
-
- for (const candidate of ladder) {
- // Rated for this tile before it is compared to anything: `building` is
- // what the tile is running and `load` is what the layout measured it
- // doing, both in this tile's units, and the ladder is the plain roster.
- // Ascending order survives the rating, since one tile scales every
- // candidate by the same factor.
- const rated = ctx.rate(tile, candidate);
- // Ascending, so nothing smaller is left to try. Both sides are `rate`'s
- // units, and one tile scales every candidate by the same factor, so the
- // ladder's order survives whichever units this is asked in.
- if (rated.effectiveValue >= building.effectiveValue) break;
- // Covering the load is asked in the layout's units, which is `rate`'s
- // plus whatever `simulateIsland` resolves per layout — see
- // `ratedCapacity`. `load` came off a row it measured, so this is the one
- // comparison here that cannot be made in the roster's units.
- if (!covers(ratedCapacity(ctx, current, tile, rated), load)) continue;
-
- put(current, ctx, tile, rated);
- const trial = simulateIsland(current, ctx);
-
- if (
- covers(trial.totalPower, floor) &&
- !anyOfflineProducer(current, trial.placements) &&
- planHolds(current, ctx)
- ) {
- power = trial.totalPower;
- rows = trial.placements;
- rowAt.clear();
- for (const r of rows) rowAt.set(r.idx, r);
- changed = true;
- break;
- }
-
- put(current, ctx, tile, building);
- }
- }
- }
-
- return { rows, power };
-}
-
-/**
- * How long `gatherPooledCoolers` may spend, per solve on the primary layout and
- * once more shared across its alternates. It runs after the search's deadline,
- * so this is time added to a run. Moving a cooler onto an empty tile costs
- * nothing. The cap is for the cooler-to-producer swaps, which mostly fail and
- * cost a simulation each. On the shipped maps one layout finishes in 15-110ms.
- */
-const GATHER_BUDGET_MS = 150;
-
-/**
- * How long `evacuateMinorLandmasses` may spend, on top of `GATHER_BUDGET_MS`
- * and taken first. Spent only on a board with producers on more than one
- * landmass, and the pass stops as soon as a hub is placed — moving a
- * 15-producer hub costs about 170ms on a desktop. The cap is sized for a phone,
- * several times slower, where a tight one would make the pass silently do
- * nothing; a second is small beside a 10-30s attempt. Out of time, the layout
- * stays as it was.
- */
-const EVACUATE_BUDGET_MS = 1000;
-
-/** Chebyshev radius of the patch `gatherPooledCoolers` anchors on. */
-const GATHER_ANCHOR_RADIUS = 2;
-
-/** Each tile's landmass, numbered from 0, and how many there are. */
-function landmasses(ctx: IslandContext): {
- component: Int32Array;
- count: number;
-} {
- const component = new Int32Array(ctx.n).fill(-1);
- let count = 0;
- for (let start = 0; start < ctx.n; start++) {
- if (component[start] >= 0) continue;
- const id = count++;
- const stack = [start];
- component[start] = id;
- while (stack.length > 0) {
- const nb = ctx.neighbors[stack.pop()!];
- for (let k = 0; k < nb.length; k++) {
- if (component[nb[k]] < 0) {
- component[nb[k]] = id;
- stack.push(nb[k]);
- }
- }
- }
- }
- return { component, count };
-}
-
-/**
- * Under a shared cooling pool, moves the hubs off every landmass but the main
- * one, so that landmass can be given to the coolers.
+ * Right-sizing is the odd one out: it is not part of the search and cannot find
+ * a better layout. It runs once the layout is final and swaps each building for
+ * the smallest tier that still carries the load that layout gives it, because
+ * power is blind to the difference between a cooler running flat out and one
+ * running at 2% — and the player is not.
*
- * `gatherPooledCoolers` moves one tile at a time, and that cannot move a hub:
- * a generator taken from its reactors loses power at every step of the way, so
- * the hub on a small second landmass stays there and its coolers stay with it.
- * This moves a landmass's producers all at once instead. Each takes a tile on
- * the main landmass, the one holding the most producers, and the cooler it
- * displaces goes to one of the tiles the producers left — so the pool's total
- * and the board's composition never change, and the landmass comes out
- * producer-free for the sweep to fill.
+ * **The stability rule.** A solved layout must be fully stable: every generator
+ * and direct producer it places has to actually run. A layout can score higher
+ * by parking reactor heat in a generator that is never cooled — nothing then
+ * has to cool that share of the waste — and it is still the wrong answer, so
+ * only stable layouts are ever recorded as best. "Pruning never reduces power"
+ * is NOT a valid invariant here; "the returned layout is stable" is.
*
- * The producers are placed greedily, whichever of them is worth the most power
- * on whichever free tile goes next, and then the main landmass is swapped
- * into shape until power is back (`relocateHub`). A landmass is evacuated only if the result
- * holds the power floor, keeps every producer online and passes the upgrade
- * plan; otherwise the layout is left exactly as it was. Smallest first, since a
- * hub of a few producers is the likeliest to fit.
+ * **Under an upgrade plan "stable" means stable at every step.** The search
+ * scores at the plan's target, but every gate that admits a layout — the best
+ * record, repair, the composition swaps, pruning, right-sizing, the tie
+ * shortlist and `stabilize` itself — also asks `planHolds`, so a layout that
+ * overheats at any tier on the way is never kept. Without a plan that call
+ * returns at once and nothing here changes.
*/
-function evacuateMinorLandmasses(
- current: Placement,
- ctx: IslandContext,
- component: Int32Array,
- count: number,
- floor: number,
- deadlineMs: number,
-): void {
- const producersOn = new Array(count).fill(0);
- for (let t = 0; t < ctx.n; t++) {
- const b = current[t];
- if (b !== null && b.type !== "cooler") producersOn[component[t]]++;
- }
- let main = 0;
- for (let c = 1; c < count; c++)
- if (producersOn[c] > producersOn[main]) main = c;
-
- const minors: number[] = [];
- for (let c = 0; c < count; c++)
- if (c !== main && producersOn[c] > 0) minors.push(c);
- minors.sort((a, b) => producersOn[a] - producersOn[b] || a - b);
-
- const trial = current.slice();
- for (const minor of minors) {
- if (performance.now() >= deadlineMs) return;
- copyInto(trial, current);
- if (relocateHub(trial, ctx, component, minor, main, floor, deadlineMs))
- copyInto(current, trial);
- }
-}
+/** Share of the time budget reserved for repair before and after annealing. */
+const POLISH_SHARE = 0.3;
/**
- * Moves every producer on `minor` onto `main`, in place. True when the result
- * is a layout `evacuateMinorLandmasses` may keep; `trial` is junk otherwise.
+ * The least time the plain-sized seed's repair is given under a role
+ * isolation, when the sized seed's own repair took less (see `solveIsland`).
+ * A 20-tile island repairs in 3ms, and a cap that tight would be timer noise.
*/
-function relocateHub(
- trial: Placement,
- ctx: IslandContext,
- component: Int32Array,
- minor: number,
- main: number,
- floor: number,
- deadlineMs: number,
-): boolean {
- const movers: EffectiveBuilding[] = [];
- const vacated: number[] = [];
- for (let t = 0; t < ctx.n; t++) {
- const b = trial[t];
- if (component[t] !== minor || b === null || b.type === "cooler") continue;
- movers.push(b);
- vacated.push(t);
- trial[t] = null;
- }
-
- // A tile on the main landmass a producer may take: empty, or a cooler that
- // can step across to the landmass being emptied.
- const free = (t: number): boolean =>
- component[t] === main && (trial[t] === null || trial[t]!.type === "cooler");
- const freeAround = (t: number): number => {
- let n = 0;
- for (const u of ctx.neighbors[t]) if (free(u)) n++;
- return n;
- };
- // A reactor makes power only through a generator beside it, so a tile with
- // none is not worth simulating.
- const besideGenerator = (t: number): boolean => {
- for (const u of ctx.neighbors[t])
- if (trial[u] !== null && trial[u]!.type === "generator") return true;
- return false;
- };
-
- let nextVacated = 0;
- while (movers.length > 0) {
- if (performance.now() >= deadlineMs) return false;
-
- let bestPower = -Infinity;
- let bestRoom = -1;
- let bestTile = -1;
- let bestMover = -1;
- // A reactor beside no generator is still placed when nothing better is
- // left — a generator that took the strongest tile may have taken the last
- // free one beside it too — and the swap descent below moves it into place.
- for (const anywhere of [false, true]) {
- const tried = new Set();
- for (let m = 0; m < movers.length; m++) {
- const b = movers[m];
- if (tried.has(b.id)) continue;
- tried.add(b.id);
- for (const t of ctx.tiles) {
- if (!free(t)) continue;
- if (!anywhere && isReactor(b) && !besideGenerator(t)) continue;
- const held = trial[t];
- put(trial, ctx, t, b);
- const power = simulateIsland(trial, ctx).totalPower;
- trial[t] = held;
- // On a tie, the tile with the most room left around it, so a
- // generator lands where its reactors can follow.
- const room = freeAround(t);
- if (power > bestPower || (power === bestPower && room > bestRoom)) {
- bestPower = power;
- bestRoom = room;
- bestTile = t;
- bestMover = m;
- }
- }
- }
- if (bestTile >= 0) break;
- }
- if (bestTile < 0) return false;
+const PLAIN_SEED_POLISH_MIN_MS = 20;
- const displaced = trial[bestTile];
- put(trial, ctx, bestTile, movers[bestMover]);
- // The displaced cooler crosses over at once, so the pool is whole while
- // the rest of the hub is placed.
- if (displaced !== null) put(trial, ctx, vacated[nextVacated++], displaced);
- movers.splice(bestMover, 1);
- }
-
- // The greedy order cannot see that a reactor placed early sits on the tile a
- // later generator wanted, and the hubs already on the main landmass were
- // built around a board that had no room for these. So: a swap descent over
- // the whole landmass, nearby pairs only (as `arrangeComposition` does), first
- // gain taken, until the floor is met. Swaps never change what is on the
- // board, and the emptied landmass is not in the pairs, so nothing returns to
- // it. On a cleared Entropy Isles under Cryo the greedy reaches 98.5% of the
- // floor and one pass of this closes the rest.
- const onMain: number[] = [];
- for (const t of ctx.tiles) if (component[t] === main) onMain.push(t);
- const pairs: [number, number][] = [];
- for (let i = 0; i < onMain.length; i++)
- for (let j = i + 1; j < onMain.length; j++)
- if (chebyshev(ctx, onMain[i], onMain[j]) <= SWAP_RADIUS)
- pairs.push([onMain[i], onMain[j]]);
-
- const producer = (b: EffectiveBuilding | null): boolean =>
- b !== null && b.type !== "cooler";
- let power = simulateIsland(trial, ctx).totalPower;
- let improved = true;
- while (improved && !covers(power, floor)) {
- improved = false;
- for (const [a, b] of pairs) {
- if (performance.now() >= deadlineMs) return false;
- const va = trial[a];
- const vb = trial[b];
- // Coolers and empty tiles are interchangeable under the pool.
- if (!producer(va) && !producer(vb)) continue;
- if (ratedFor(ctx, a, vb) === va) continue;
- put(trial, ctx, a, vb);
- put(trial, ctx, b, va);
- const swapped = simulateIsland(trial, ctx).totalPower;
- if (swapped > power) {
- power = swapped;
- improved = true;
- if (covers(power, floor)) break;
- continue;
- }
- put(trial, ctx, a, va);
- put(trial, ctx, b, vb);
- }
- }
-
- const final = simulateIsland(trial, ctx);
- return (
- covers(final.totalPower, floor) &&
- !anyOfflineProducer(trial, final.placements) &&
- planHolds(trial, ctx)
- );
-}
+/** Share of the time budget spent arranging those compositions. */
+const COMPOSITION_SHARE = 0.2;
/**
- * Under a shared cooling pool, pulls the coolers into one area of the board
- * and onto the small islands no producer is using.
- *
- * Under the pool a cooler's tile does not matter. Every cooler pays into one
- * figure (`simulateIsland`), so power cannot tell a cooler in the middle of the
- * hubs from one at the edge. The walk leaves coolers wherever a hub left a gap,
- * scattered across the landmass, and that board is harder to read and to build
- * than the same layout with its coolers together.
- *
- * Like `downgradeOversized`, this cannot find a better layout. It only moves
- * coolers, swapping each with a tile nearer the anchor. Moving onto an empty
- * tile always holds, because the pool's total and every producer are
- * untouched. A swap with a producer moves that producer into the cooler's old
- * tile, so it is re-simulated and kept only if power holds, every producer
- * stays online and the upgrade plan still runs.
- *
- * The anchor is the tile with the most coolers within `GATHER_ANCHOR_RADIUS`,
- * so the coolers gather on the patch where most of them already stand. Anchoring
- * on the coolers' centroid was measured too: it put the anchor among the hubs,
- * where a swap rarely holds, and left more clusters on maps 1 and 8. A tile on a
- * landmass with no producer is a scrap nothing else can use, and counts as
- * nearer than any tile on a working landmass, so empty scraps fill first. Every
- * accepted move strictly lowers the coolers' summed distance, so the sweep
- * terminates; the deadline only bounds how far it gets.
+ * The closing repair is reserved a share of the budget up front, then handed
+ * back whatever it does not use (see `solveIsland`). These bound the handback:
+ * the repair is left twice its measured cost as headroom, but never more than
+ * half of what is left — on a big island one sweep costs seconds, and reserving
+ * twice that would strand more time than it protects. A reclaimed round shorter
+ * than the floor is not worth its own setup.
*/
-function gatherPooledCoolers(
- rows: SimPlacedBuilding[],
- power: number,
- effectiveBuildings: EffectiveBuilding[],
- ctx: IslandContext,
- deadlineMs: number,
- evacuateDeadlineMs = Math.min(
- deadlineMs,
- performance.now() + EVACUATE_BUDGET_MS,
- ),
-): { rows: SimPlacedBuilding[]; power: number } {
- if (ctx.anomaly.rule !== "shared_cooling" || rows.length === 0)
- return { rows, power };
-
- const byId = new Map(effectiveBuildings.map((b) => [b.id, b]));
- const current = emptyPlacement(ctx.n);
- for (const row of rows) {
- const building = byId.get(row.buildingId);
- if (building === undefined) return { rows, power };
- put(current, ctx, row.idx, building);
- }
-
- const { component, count } = landmasses(ctx);
- // The callers' deadline covers both budgets; capping the evacuation at its
- // own (the default) leaves the sweep at least `GATHER_BUDGET_MS`.
- evacuateMinorLandmasses(
- current,
- ctx,
- component,
- count,
- power,
- evacuateDeadlineMs,
- );
-
- // Which landmasses hold a producer, read after the evacuation so a landmass
- // it emptied fills with coolers first, like any other scrap.
- const working = new Array(count).fill(false);
- for (let t = 0; t < ctx.n; t++) {
- const b = current[t];
- if (b !== null && b.type !== "cooler") working[component[t]] = true;
- }
-
- const movable = (t: number): boolean => {
- const b = current[t];
- return b !== null && b.type === "cooler" && working[component[t]];
- };
- const placed: number[] = [];
- for (let t = 0; t < ctx.n; t++) if (movable(t)) placed.push(t);
- if (placed.length === 0) return { rows, power };
-
- let anchor = -1;
- let densest = -1;
- for (let t = 0; t < ctx.n; t++) {
- if (!working[component[t]]) continue;
- let near = 0;
- for (const u of placed)
- if (chebyshev(ctx, t, u) <= GATHER_ANCHOR_RADIUS) near++;
- if (near > densest) {
- densest = near;
- anchor = t;
- }
- }
-
- /** Squared distance to the anchor; a scrap tile is nearer than any. */
- const distance = new Float64Array(ctx.n);
- for (let t = 0; t < ctx.n; t++) {
- distance[t] = working[component[t]]
- ? (ctx.xs[t] - ctx.xs[anchor]) ** 2 + (ctx.ys[t] - ctx.ys[anchor]) ** 2
- : -1;
- }
- // Nearest first, ascending index on a tie, so the result depends only on the
- // board.
- const byDistance = Array.from(ctx.tiles).sort(
- (a, b) => distance[a] - distance[b] || a - b,
- );
-
- const floor = power;
- let changed = true;
- while (changed && performance.now() < deadlineMs) {
- changed = false;
- // Farthest cooler first: it has the most to gain from a move.
- const coolers: number[] = [];
- for (let t = 0; t < ctx.n; t++) if (movable(t)) coolers.push(t);
- coolers.sort((a, b) => distance[b] - distance[a] || a - b);
-
- for (const from of coolers) {
- if (performance.now() >= deadlineMs) break;
- const cooler = current[from]!;
- for (const to of byDistance) {
- if (distance[to] >= distance[from]) break;
- const other = current[to];
- if (other !== null && other.type === "cooler") continue;
-
- put(current, ctx, to, cooler);
- put(current, ctx, from, other);
- if (other === null) {
- // The pool's total and every producer are unchanged.
- changed = true;
- break;
- }
- const trial = simulateIsland(current, ctx);
- if (
- covers(trial.totalPower, floor) &&
- !anyOfflineProducer(current, trial.placements) &&
- planHolds(current, ctx)
- ) {
- changed = true;
- break;
- }
- put(current, ctx, from, cooler);
- put(current, ctx, to, other);
- if (performance.now() >= deadlineMs) break;
- }
- }
- }
+const RECLAIM_POLISH_MARGIN = 2.0;
+const RECLAIM_MAX_RESERVE_FRACTION = 0.5;
+const RECLAIM_MIN_ROUND_SHARE = 0.01;
+const RECLAIM_MIN_ROUND_MS = 50;
- const final = simulateIsland(current, ctx);
- // A move to an empty tile is exact, and a swap had to hold the floor, so a
- // shortfall here would be a bug rather than a trade. Give the input back.
- if (!covers(final.totalPower, floor)) return { rows, power };
- return { rows: final.placements, power: final.totalPower };
+export interface IslandSolution extends IslandLayout {
+ /**
+ * Other layouts this island's search finished tied with, best-power-first
+ * and never including the primary. Empty when the search found no second
+ * arrangement at the same power. See `alternates.ts` — they are gathered by
+ * watching the walk, and every one of them is stable and pruned exactly as
+ * the primary is.
+ */
+ alternates?: IslandLayout[];
}
/**
@@ -2589,13 +168,14 @@ function gatherPooledCoolers(
* onto the same handful of tiles. This is the gate that actually holds — it is
* the last place the layouts change shape.
*/
-function finalizeAlternates(
+async function finalizeAlternates(
collector: AlternateCollector,
primaryRows: PlacedBuilding[],
primaryPower: number,
effectiveBuildings: EffectiveBuilding[],
ctx: IslandContext,
-): IslandLayout[] {
+ pacer: Pacer,
+): Promise {
const kept: PlacedBuilding[][] = [primaryRows];
const out: IslandLayout[] = [];
// One gathering budget for the whole shortlist rather than one each, so a
@@ -2605,6 +185,16 @@ function finalizeAlternates(
performance.now() + EVACUATE_BUDGET_MS + GATHER_BUDGET_MS;
for (const layout of collector.layouts()) {
+ // This runs after the search's deadline, and each alternate is a few
+ // hundred simulations — up to a second under a cooling pool — so come up
+ // for air between them: a STOP that arrives here ends the shortlist where
+ // it stands rather than waiting for ten more layouts to be finished.
+ const now = performance.now();
+ if (pacer.due(now)) {
+ await pacer.pump(now);
+ if (pacer.stopRequested) break;
+ }
+
const pruned = pruneDeadWeight(layout, ctx);
const downsized = downgradeOversized(
pruned.rows,
@@ -2630,30 +220,6 @@ function finalizeAlternates(
return out;
}
-function islandProgress(
- placement: Placement,
- ctx: IslandContext,
-): IslandProgress {
- const pruned = pruneDeadWeight(placement, ctx);
- return {
- placements: toPlacedBuildings(pruned.rows),
- powerOutput: pruned.power,
- };
-}
-
-// ---------------------------------------------------------------------------
-// Entry point
-// ---------------------------------------------------------------------------
-
-/**
- * Solves one island.
- *
- * `rngSeed` fixes the random stream of the annealing walk. Same seed, same move
- * sequence — but stage deadlines are wall-clock, so where each stage stops (and
- * the temperature at a given step) still varies slightly between runs; seeding
- * narrows run-to-run variance rather than eliminating it. Omitting it draws a
- * fresh seed, preserving the stochastic default.
- */
/**
* Deterministic replay of `solveIsland`'s reproducible stages, for the golden
* fixture harness. Not used in production.
@@ -2738,6 +304,16 @@ export async function replayIslandDeterministic(
};
}
+/**
+ * Solves one island.
+ *
+ * `rngSeed` fixes the random stream of the annealing walk. Same seed, same move
+ * sequence — but stage deadlines are wall-clock, so where each stage stops (and
+ * the temperature at a given step) still varies slightly between runs; seeding
+ * narrows run-to-run variance rather than eliminating it. Omitting it draws a
+ * fresh seed, preserving the stochastic default.
+ */
+
export async function solveIsland(
island: IslandSubGrid,
effectiveBuildings: EffectiveBuilding[],
@@ -2866,12 +442,49 @@ export async function solveIsland(
Math.min(deadlineMs, polishStartedMs + polishBudgetMs),
pacer,
);
- let placement = polished.placement;
- let bestPower = polished.power;
// How long a full repair actually took here, used below to reserve just
// enough for the closing pass instead of a fixed (over-generous) share.
const polishElapsedMs = performance.now() - polishStartedMs;
+ // Under a role isolation the seed above was sized for it (`constructSeed`),
+ // and the plain-sized seed is built and repaired as well; the search starts
+ // from whichever repairs higher, the plain one on a tie. Judged after the
+ // repair rather than before it, because the raw figure picks wrong: on
+ // Entropy Isles' 20-tile island both seeds repair to five full hubs at the
+ // same power, and from the sized one — 20 tiles of 20 used, every
+ // generator already full — the later stages never reach the six-generator
+ // optimum the plain one leads them to (2.36AD against 2.54AD at its 0.3s
+ // budget). On every large shipped island the sized seed repairs to 78-88%
+ // of the bound against the plain one's 50-64%, so the tie rule only
+ // decides where the sizing did not help, and there the search starts where
+ // every other rule starts. The plain repair gets the time the sized one
+ // took, so this costs at most one more repair; cut short it only reads
+ // lower, and the sized seed wins as it should.
+ if (ctx.isolation !== null) {
+ const plainSeed = constructMultiStartSeed(
+ ctx,
+ reactors,
+ generators,
+ coolers,
+ directProducers,
+ deadlineMs,
+ false,
+ );
+ const plainPolished = await greedyPolish(
+ plainSeed,
+ ctx,
+ candidates,
+ Math.min(
+ deadlineMs,
+ performance.now() + Math.max(polishElapsedMs, PLAIN_SEED_POLISH_MIN_MS),
+ ),
+ pacer,
+ );
+ if (plainPolished.power + EPS >= polished.power) polished = plainPolished;
+ }
+ let placement = polished.placement;
+ let bestPower = polished.power;
+
// Composition-first attempt, BEFORE annealing so its result becomes the
// walk's starting point rather than competing with it for time. What to build
// is a counting problem with an exact answer, so solve that directly instead
@@ -2955,6 +568,13 @@ export async function solveIsland(
RECLAIM_MIN_ROUND_MS,
timeBudgetMs * RECLAIM_MIN_ROUND_SHARE,
);
+ //
+ // Each round reheats to the walk's full starting temperature. Starting the
+ // reclaimed rounds at a tenth of it — on the reasoning that a short round
+ // from a repaired layout spends most of itself wandering off a layout it
+ // then hands straight back — was measured with the survey at 15s over three
+ // runs on every map and anomaly: every map inside the noise band, so the
+ // one schedule is kept.
for (;;) {
if (annealMs > 0 && !pacer.stopRequested) {
placement = await hillClimb(
@@ -2998,6 +618,14 @@ export async function solveIsland(
if (annealMs < minRoundMs) break;
}
+ // Everything from here runs past the deadline and none of it yields on its
+ // own, so yield once before it: the walk's last pump may be most of a yield
+ // interval ago, and the cleanup below is a second of work under a pool.
+ {
+ const now = performance.now();
+ if (pacer.due(now)) await pacer.pump(now);
+ }
+
// Post-search cleanup pass...
const pruned = pruneDeadWeight(placement, ctx);
// ...and then right-size what survived. The search had no reason to prefer
@@ -3022,12 +650,13 @@ export async function solveIsland(
return {
placements,
powerOutput: sized.power,
- alternates: finalizeAlternates(
+ alternates: await finalizeAlternates(
collector,
placements,
sized.power,
effectiveBuildings,
ctx,
+ pacer,
),
};
}
diff --git a/packages/solver/src/solver/search/anneal.ts b/packages/solver/src/solver/search/anneal.ts
new file mode 100644
index 0000000..1dc5a68
--- /dev/null
+++ b/packages/solver/src/solver/search/anneal.ts
@@ -0,0 +1,324 @@
+import { AlternateCollector, powerTies } from "../alternates";
+import { EPS } from "../constants";
+import type { IslandContext } from "../context";
+import { Pacer } from "../pacer";
+import { generatorEnergyRatio, wasteIsCovered } from "../physics";
+import { Rng } from "../rng";
+import { type SimPlacedBuilding, simulateIsland } from "../simulate";
+import type { EffectiveBuilding, Placement } from "../types";
+import { copyInto, put, ratedFor } from "./placement";
+import { islandProgress } from "./repair";
+import {
+ anyOfflineProducer,
+ isUnderFed,
+ planHolds,
+ stabilize,
+} from "./stability";
+
+/**
+ * The annealing walk: the one stage that can cross a barrier no single change
+ * improves upon.
+ */
+
+function randomCandidate(
+ pools: EffectiveBuilding[][],
+ rng: Rng,
+): EffectiveBuilding | null {
+ if (pools.length === 0 || rng.random() < 0.15) return null;
+ const pool = rng.choice(pools);
+ if (pool.length === 1 || rng.random() < 0.7) return pool[0];
+ return rng.choice(pool);
+}
+
+/**
+ * `maxSteps` caps the walk by iteration count and, when set, drives the
+ * temperature schedule by `step / maxSteps` instead of elapsed time. With a
+ * fixed `Rng` and a `timeBudgetMs` generous enough that the deadline never
+ * fires first, the walk is then fully deterministic: same seed, same result,
+ * bit for bit. That is what the golden fixtures stand on. The solve pipeline
+ * itself never sets it — wall time is the right budget in production, where
+ * per-step cost varies by island.
+ */
+export async function hillClimb(
+ seed: Placement,
+ ctx: IslandContext,
+ reactors: EffectiveBuilding[],
+ generators: EffectiveBuilding[],
+ coolers: EffectiveBuilding[],
+ directProducers: EffectiveBuilding[],
+ timeBudgetMs: number,
+ rng: Rng,
+ pacer: Pacer,
+ maxSteps?: number,
+ collector?: AlternateCollector,
+): Promise {
+ if (timeBudgetMs <= 0 || ctx.n === 0) return seed;
+
+ const current = seed.slice();
+ let sim = simulateIsland(current, ctx);
+ let currentPower = sim.totalPower;
+ let currentPlacements = sim.placements;
+
+ // The walk itself is free to pass through layouts containing overheating
+ // buildings — they are often one move away from good ones — but only a STABLE
+ // layout may be recorded as the best. Raw power alone would happily settle on
+ // a layout that parks heat in a permanently offline generator, which then has
+ // to be dismantled at the end, ending up worse than the best stable layout
+ // the search already walked past.
+ const initial = stabilize(current, ctx);
+ let bestPlacement = initial.placement;
+ let bestPower = initial.power;
+ // The layout the walk starts from is itself an answer at this power, and the
+ // shortlist has to hold one before it can recognise a tie with it.
+ collector?.offer(initial.power, initial.placement);
+ // Highest raw score seen so far, used only to decide when an unstable layout
+ // is promising enough to be worth stabilizing and re-scoring.
+ let bestRawPower = currentPower;
+ let stall = 0;
+
+ const recordIfBest = (
+ power: number,
+ snapshot: Placement,
+ rows: SimPlacedBuilding[],
+ ): void => {
+ if (power <= bestPower + EPS) {
+ // Not an improvement — but a layout that *ties* the best is a second
+ // answer worth offering the user, so it goes on the shortlist. The
+ // checks are ordered by what they cost: `full` is a size comparison,
+ // `accepts` reads the layout's tiles, and only then does the stability
+ // test walk every row. A converged walk ties its best constantly and
+ // nearly all of those are near copies of a layout already held, so
+ // `accepts` is what keeps this off the hot path once the shortlist has
+ // stopped growing.
+ if (
+ collector !== undefined &&
+ !collector.full &&
+ powerTies(power, collector.power) &&
+ collector.accepts(snapshot) &&
+ !anyOfflineProducer(snapshot, rows) &&
+ planHolds(snapshot, ctx)
+ ) {
+ collector.offer(power, snapshot);
+ }
+ return;
+ }
+
+ if (!anyOfflineProducer(snapshot, rows) && planHolds(snapshot, ctx)) {
+ bestPower = power;
+ bestPlacement = snapshot.slice();
+ collector?.offer(power, snapshot);
+ stall = 0;
+ return;
+ }
+
+ // Unstable. Only worth the extra simulations when it is a new raw
+ // high-water mark — otherwise the walk would re-stabilize the same
+ // unreachable peak on every visit.
+ if (power > bestRawPower + EPS) {
+ bestRawPower = power;
+ const stabilized = stabilize(snapshot, ctx);
+ if (stabilized.power > bestPower + EPS) {
+ bestPower = stabilized.power;
+ bestPlacement = stabilized.placement;
+ collector?.offer(stabilized.power, stabilized.placement);
+ stall = 0;
+ }
+ }
+ };
+
+ const topCooler = coolers.length > 0 ? coolers[0] : null;
+ const topGenerator = generators.length > 0 ? generators[0] : null;
+ const pools = [reactors, generators, coolers, directProducers].filter(
+ (p) => p.length > 0,
+ );
+ const startMs = performance.now();
+ const deadlineMs = startMs + timeBudgetMs;
+
+ // Temperature is scaled to the size of one MOVE, not to the island's total
+ // output. Changing a single tile is worth roughly one generator's power
+ // wherever it happens, so scaling by total power made large islands run far
+ // too hot: on the 67-tile map 1 island, ~23% of moves that each cost 7% of
+ // the layout were being accepted, and the walk never climbed back.
+ //
+ // It is read off the PLAIN roster, so under a rule that rates a tile above it
+ // the walk runs colder than this calibration intends — a move on a Tidal
+ // shore is worth its `multiplier` times one here. Scaling it by
+ // `islandRatingCeiling` was measured: Tidal (the pre-nerf
+ // `tidal_ascendancy_legacy`) on map 3 at 15s over three seeds went
+ // 176/178/179AC to 179/181/178AC, and Singularity at 30s over five seeds on
+ // map 3 142/140/142/142/140AC to
+ // 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to
+ // 267/271/271/271/274AC — inside run-to-run noise, so the plain figure is kept.
+ // Anyone re-measuring should use a longer budget and more seeds than that.
+ const moveScale =
+ generators.length > 0
+ ? generators[0].effectiveValue * generatorEnergyRatio(generators[0])
+ : 1.0;
+ const tStart = Math.max(moveScale * 0.1, 1.0);
+ const tMin = 1e-4;
+ let temperature = tStart;
+
+ // Snap back to the best layout after a long run of no improvement. Without
+ // this, one unlucky sequence of accepted downhill moves strands the walk in a
+ // region it cannot climb out of for the rest of the budget.
+ const stallLimit = Math.max(2000, ctx.n * 200);
+
+ let step = 0;
+ const batchMask = 127;
+
+ /** Accepts or rolls back a trial move, Metropolis-style. */
+ const accept = (power: number, rows: SimPlacedBuilding[]): boolean => {
+ const delta = power - currentPower;
+ const exponent = temperature > 0 ? delta / temperature : -100.0;
+ if (
+ delta > EPS ||
+ (exponent > -50.0 && rng.random() < Math.exp(exponent))
+ ) {
+ currentPower = power;
+ currentPlacements = rows;
+ recordIfBest(power, current, rows);
+ return true;
+ }
+ return false;
+ };
+
+ for (;;) {
+ if (maxSteps !== undefined && step >= maxSteps) break;
+
+ if ((step & batchMask) === 0) {
+ const now = performance.now();
+ if (now >= deadlineMs) break;
+
+ if (pacer.due(now)) {
+ await pacer.pump(now, () => islandProgress(bestPlacement, ctx));
+ if (pacer.stopRequested) break;
+ }
+
+ const progress =
+ maxSteps !== undefined
+ ? Math.min(1.0, step / maxSteps)
+ : Math.min(1.0, Math.max(0.0, (now - startMs) / timeBudgetMs));
+ temperature = tStart * Math.pow(tMin / tStart, progress);
+
+ if (stall >= stallLimit) {
+ copyInto(current, bestPlacement);
+ sim = simulateIsland(current, ctx);
+ currentPower = sim.totalPower;
+ currentPlacements = sim.placements;
+ stall = 0;
+ }
+ }
+
+ step += 1;
+ stall += 1;
+ const moveType = rng.random();
+
+ if (moveType < 0.15 && currentPlacements.length > 0) {
+ const uncooled: SimPlacedBuilding[] = [];
+ const wastedReactors: SimPlacedBuilding[] = [];
+ for (const p of currentPlacements) {
+ if (
+ p.wasteHeatGenerated > EPS &&
+ !wasteIsCovered(p.wasteHeatGenerated, p.coolingReceived)
+ ) {
+ uncooled.push(p);
+ }
+ if (isUnderFed(p)) {
+ wastedReactors.push(p);
+ }
+ }
+
+ if (uncooled.length > 0 && topCooler !== null && rng.random() < 0.6) {
+ const target = rng.choice(uncooled);
+ const adj = ctx.neighbors[target.idx];
+ // Beside the starved producer, which is the only place a cooler can
+ // reach it. Under a pool any tile would do and a short pool starves
+ // every producer at once, so this writes over a random occupied
+ // neighbour there — often the reactor feeding the very producer it
+ // means to save. Aiming it at an empty tile instead was measured on
+ // maps 3 and 7 at 15s over three seeds and changed nothing, so the one
+ // shape is kept.
+ if (adj.length > 0) {
+ const tile = adj[rng.int(adj.length)];
+ const old = current[tile];
+ // In the tile's units, or the guard never fires on a rated tile —
+ // see `ratedFor`.
+ if (ratedFor(ctx, tile, topCooler) !== old) {
+ put(current, ctx, tile, topCooler);
+ const trial = simulateIsland(current, ctx);
+ if (!accept(trial.totalPower, trial.placements))
+ put(current, ctx, tile, old);
+ continue;
+ }
+ }
+ } else if (
+ wastedReactors.length > 0 &&
+ topGenerator !== null &&
+ rng.random() < 0.6
+ ) {
+ const target = rng.choice(wastedReactors);
+ const adj = ctx.neighbors[target.idx];
+ if (adj.length > 0) {
+ const tile = adj[rng.int(adj.length)];
+ const old = current[tile];
+ if (ratedFor(ctx, tile, topGenerator) !== old) {
+ put(current, ctx, tile, topGenerator);
+ const trial = simulateIsland(current, ctx);
+ if (!accept(trial.totalPower, trial.placements))
+ put(current, ctx, tile, old);
+ continue;
+ }
+ }
+ }
+ }
+
+ if (moveType < 0.5 && ctx.n >= 2) {
+ const tile1 = rng.int(ctx.n);
+ const adj = ctx.neighbors[tile1];
+ let tile2: number;
+ if (adj.length > 0 && rng.random() < 0.7) {
+ tile2 = adj[rng.int(adj.length)];
+ } else {
+ tile2 = rng.int(ctx.n);
+ while (tile2 === tile1) tile2 = rng.int(ctx.n);
+ }
+
+ const val1 = current[tile1];
+ const val2 = current[tile2];
+ // A swap that writes each tile what it already holds. On one scale class
+ // that is `val1 === val2`; across two it is not, because the same roster
+ // entry is a different object on each — so the test is on what the writes
+ // would land, which covers both.
+ if (
+ ratedFor(ctx, tile1, val2) === val1 &&
+ ratedFor(ctx, tile2, val1) === val2
+ ) {
+ continue;
+ }
+
+ put(current, ctx, tile1, val2);
+ put(current, ctx, tile2, val1);
+
+ const trial = simulateIsland(current, ctx);
+ if (!accept(trial.totalPower, trial.placements)) {
+ put(current, ctx, tile1, val1);
+ put(current, ctx, tile2, val2);
+ }
+ continue;
+ }
+
+ const tile = rng.int(ctx.n);
+ const old = current[tile];
+ const replacement = randomCandidate(pools, rng);
+ // The commonest move in the walk, and the commonest no-op: `randomCandidate`
+ // returns the top tier of a pool with p ~ 0.7, so a packed island proposes
+ // the building a tile already holds constantly.
+ if (ratedFor(ctx, tile, replacement) === old) continue;
+
+ put(current, ctx, tile, replacement);
+ const trial = simulateIsland(current, ctx);
+ if (!accept(trial.totalPower, trial.placements)) current[tile] = old;
+ }
+
+ return bestPlacement;
+}
diff --git a/packages/solver/src/solver/search/composition.ts b/packages/solver/src/solver/search/composition.ts
new file mode 100644
index 0000000..a7876ed
--- /dev/null
+++ b/packages/solver/src/solver/search/composition.ts
@@ -0,0 +1,465 @@
+import { EPS } from "../constants";
+import type { IslandContext } from "../context";
+import { generatorCapacities, isolationRoom } from "../island";
+import { Pacer } from "../pacer";
+import { generatorEnergyRatio, generatorWasteRatio } from "../physics";
+import { simulateIsland } from "../simulate";
+import type { EffectiveBuilding, IslandSubGrid, Placement } from "../types";
+import { chebyshev, put, ratedFor, SWAP_RADIUS } from "./placement";
+import { POLISH_REACTOR_TIERS } from "./repair";
+import { anyOfflineProducer, planHolds } from "./stability";
+
+/**
+ * Composition retargeting: deciding WHAT to build is a counting problem, so it
+ * is computed directly, and the search is left with where to put it.
+ */
+
+/** How many candidate compositions to try arranging, best-scoring first. */
+const COMPOSITION_TARGETS = 3;
+
+interface ScoredComposition {
+ power: number;
+ composition: EffectiveBuilding[];
+}
+
+/**
+ * The best MULTISETS of buildings for an island of this size, ignoring where
+ * they go — highest theoretical power first.
+ *
+ * Which buildings to use is a tiny counting problem, not a search problem. For
+ * counts (nReactor, nGenerator, nCooler) summing to the tile budget, the layout
+ * can convert at most `min(reactor output, nGenerator * H_max, nCooler * C /
+ * WASTE_RATIO)` of heat, so enumerating the O(tiles^2) splits and greedily
+ * filling the reactor tiles with the largest tiers that stay inside that budget
+ * gives the exact best composition. On the 67-tile map 1 island this returns
+ * 13 neuro + 1 sauron_eye + 14 generator6 + 39 cooler6 — the known optimum — in
+ * a few milliseconds, where the search alone plateaued 1.6% below it.
+ *
+ * Placement can still make a composition unachievable (a generator can only be
+ * fed by ADJACENT reactors), which is why several are returned, each with the
+ * power it would reach if it could be arranged perfectly: the caller tries them
+ * in order, and can skip any that could not beat what it already has.
+ */
+export function targetCompositions(
+ tileCount: number,
+ reactors: EffectiveBuilding[],
+ generators: EffectiveBuilding[],
+ coolers: EffectiveBuilding[],
+ generatorCapacity: Float64Array | null,
+ topK = COMPOSITION_TARGETS,
+): ScoredComposition[] {
+ if (
+ reactors.length === 0 ||
+ generators.length === 0 ||
+ coolers.length === 0 ||
+ tileCount < 3
+ ) {
+ return [];
+ }
+
+ const generator = generators[0];
+ const cooler = coolers[0];
+ // Only the `topK` best are kept as they are found, and a composition's
+ // array is built only once it has earned a place. Materialising all
+ // O(tiles^2) of them and sorting afterwards cost 80-130ms on the largest
+ // shipped islands, nearly all of it allocation. Insertion goes after every
+ // entry of equal power, which is exactly what the stable descending sort it
+ // replaces produced: equal scores in enumeration order.
+ const scored: ScoredComposition[] = [];
+ const earnsPlace = (power: number): boolean =>
+ scored.length < topK || power > scored[scored.length - 1].power;
+ const keep = (entry: ScoredComposition): void => {
+ let at = scored.length;
+ while (at > 0 && scored[at - 1].power < entry.power) at--;
+ scored.splice(at, 0, entry);
+ if (scored.length > topK) scored.pop();
+ };
+
+ for (let nGen = 1; nGen < tileCount - 1; nGen++) {
+ for (let nCool = 1; nCool < tileCount - nGen; nCool++) {
+ const nReact = tileCount - nGen - nCool;
+ if (nReact < 1) continue;
+
+ // Most heat this many generators, and this much cooling, can carry.
+ const wasteRatio = generatorWasteRatio(generator);
+ if (wasteRatio <= 0) continue;
+ const budget = Math.min(
+ generatorCapacity === null
+ ? nGen * generator.effectiveValue
+ : generatorCapacity[nGen],
+ (nCool * cooler.effectiveValue) / wasteRatio,
+ );
+
+ // Two ways to fill the reactor tiles, and which one wins depends on what
+ // is actually binding.
+ //
+ // Packing them with the largest tier maximizes heat. Overshooting the
+ // budget costs nothing — surplus heat is simply never absorbed and power
+ // stays capped — so this is what to do when generator or cooling capacity
+ // is the limit.
+ //
+ // Stepping down through tiers to land just under the budget instead
+ // matters when COOLING is the limit: the all-or-nothing rule shuts a
+ // generator down entirely if its waste outruns the cooling reaching it,
+ // so a smaller top-up reactor can be worth more than a larger one.
+ const fills: EffectiveBuilding[][] = [
+ new Array(nReact).fill(reactors[0]),
+ ];
+
+ let heat = 0.0;
+ const mix: EffectiveBuilding[] = [];
+ for (const tier of reactors.slice(0, POLISH_REACTOR_TIERS)) {
+ if (tier.effectiveValue <= 0) continue;
+ const take = Math.min(
+ nReact - mix.length,
+ Math.floor((budget - heat) / tier.effectiveValue),
+ );
+ if (take > 0) {
+ heat += take * tier.effectiveValue;
+ for (let i = 0; i < take; i++) mix.push(tier);
+ }
+ }
+ if (mix.length === nReact && !sameFill(mix, fills[0])) fills.push(mix);
+
+ for (const fill of fills) {
+ let fillHeat = 0.0;
+ for (const b of fill) fillHeat += b.effectiveValue;
+ const absorbed = Math.min(fillHeat, budget);
+ const power = absorbed * generatorEnergyRatio(generator);
+ if (power > EPS && earnsPlace(power)) {
+ const composition = fill.slice();
+ for (let i = 0; i < nGen; i++) composition.push(generator);
+ for (let i = 0; i < nCool; i++) composition.push(cooler);
+ keep({ power, composition });
+ }
+ }
+ }
+ }
+
+ return scored;
+}
+
+/**
+ * How much heat `nGen` generator tiles on THIS island can take in, or null
+ * under every rule that leaves the count alone.
+ *
+ * `targetCompositions` decides what to build by counting, and the count it
+ * wants is a function of what a generator is worth. Under a role isolation
+ * that is two numbers rather than one — a generator with no generator beside
+ * it takes 4x, one with 0.8x — and which a tile gets is decided by the
+ * layout, so neither belongs in the pool the stage draws on. The roster cannot
+ * express it; the island can, and `isolationRoom` is how much of the bonus its
+ * shape has room for.
+ *
+ * Both halves are load-bearing. Sizing at the bonus alone asks Gale Hills at
+ * generator7 tier 1 for 20 generators on an island that can keep 15 apart, and
+ * `arrangeComposition` cannot spread that many without most of them touching.
+ * With the room folded in it asks for 16, and over ten runs at 15s the mean
+ * goes 44.6AC to 46.2AC; the best either reaches is 49.8AC, fifteen generators
+ * every one isolated. Over three seeds it is worth +12-14% on maps 7 and 8 and
+ * costs 1-4% on maps 2, 5 and 6 — a net gain, not a uniform one.
+ *
+ * The same table the bound runs on (`island.ts`), and deliberately so — one
+ * reading of what a board has room for, rather than a second one here to drift
+ * against it. It is built from the island's own ratings rather than the
+ * capped figures the bound uses: a bound may relax adjacency away, a target
+ * has to be something the board can hold.
+ */
+export function generatorCapacityTable(
+ island: IslandSubGrid,
+ ctx: IslandContext,
+ generator: EffectiveBuilding | undefined,
+): Float64Array | null {
+ if (generator === undefined) return null;
+
+ const isolated = ctx.rateIsolated(generator, false).effectiveValue;
+ const crowded = ctx.rateIsolated(generator, true).effectiveValue;
+ // Equal under every other rule, and under one that rates a crowded building
+ // no lower there is no ceiling to respect: the count is linear again.
+ if (crowded >= isolated) return null;
+
+ return generatorCapacities(ctx.n, isolated, {
+ gTake: crowded,
+ room: isolationRoom(island),
+ });
+}
+
+/**
+ * The largest factor anything on this island can be rated at — 1 under the rules
+ * that leave the roster alone.
+ *
+ * `targetCompositions` scores a target from the plain roster, and it is right to:
+ * which buildings to use is a counting problem over the roster, and the counting
+ * is the same whatever the tiles are worth. But the layout the caller compares it
+ * against has been rated tile by tile, so the two figures are in different units,
+ * and a target's ceiling read as-is says "this cannot beat what I have" about
+ * every target on the list. The gate is a `break`, so the whole stage goes with
+ * it — silently, and hardest under the anomaly that most needs it: on island3
+ * under Tidal a 0.6s run already returns 1.68e23 against a top target of
+ * 1.49e23, so the stage never ran at any realistic budget.
+ *
+ * Scaling the target by one island-wide factor is the move `estimateTotalMaxPower`
+ * makes to keep its bound a bound wherever its mask cannot say which tiles carry
+ * the factor (`islandMaxScale`), and it is sound for the same reason: a
+ * composition's power is positively homogeneous of degree 1 in
+ * the roster's figures, so rating the whole island at its best tile is an upper
+ * bound on rating each tile at its own. Loose where only part of an island
+ * qualifies, which is the right way to be wrong here — a ceiling that is too low
+ * skips a stage that would have helped, while one that is too high costs a few
+ * arrangement attempts that fail to beat the layout in hand.
+ *
+ * It is asked of the context rather than read off the anomaly so that a rule
+ * resolved per tile (`rate`) and one resolved per layout (`rateIsolated`) are
+ * both answered by the code that actually applies them, and a fifth rule shape
+ * needs nothing here at all.
+ */
+export function islandRatingCeiling(
+ ctx: IslandContext,
+ probes: readonly EffectiveBuilding[],
+ sizedByIsolation: boolean,
+): number {
+ if (ctx.uniformRating && (ctx.isolation === null || sizedByIsolation))
+ return 1;
+
+ let ceiling = 1;
+ const consider = (factor: number): void => {
+ if (factor > ceiling) ceiling = factor;
+ };
+
+ for (const probe of probes) {
+ if (probe.effectiveValue <= 0) continue;
+ if (!ctx.uniformRating) {
+ for (let tile = 0; tile < ctx.n; tile++) {
+ consider(ctx.rate(tile, probe).effectiveValue / probe.effectiveValue);
+ }
+ }
+ if (ctx.isolation !== null && !sizedByIsolation) {
+ // Both variants, because which one a tile gets is a function of the
+ // layout: a search free to keep generators apart rates every one of them
+ // at the bonus. The same reasoning `islandMaxScale` carries.
+ consider(
+ ctx.rateIsolated(probe, false).effectiveValue / probe.effectiveValue,
+ );
+ consider(
+ ctx.rateIsolated(probe, true).effectiveValue / probe.effectiveValue,
+ );
+ }
+ }
+ return ceiling;
+}
+
+/**
+ * Whether a composition target could still beat the layout in hand, which is what
+ * decides whether the retarget stage runs at all.
+ *
+ * `target.power` is the power a perfect arrangement of the target would reach at
+ * the PLAIN roster's figures and `bestPower` is what a rated layout actually
+ * scored, so the ceiling is what puts the two in the same units — see
+ * `islandRatingCeiling`. The caller's gate is a `break` over a descending list,
+ * so answering `false` once ends the stage: a named function so the reading can
+ * be pinned, since it is the difference between a stage that runs and one that is
+ * silently never entered.
+ */
+export function targetCanBeat(
+ target: ScoredComposition,
+ bestPower: number,
+ ratingCeiling: number,
+): boolean {
+ return target.power * ratingCeiling > bestPower + EPS;
+}
+
+function sameFill(a: EffectiveBuilding[], b: EffectiveBuilding[]): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
+ return true;
+}
+
+function countById(
+ buildings: Iterable,
+): Map {
+ const counts = new Map();
+ for (const b of buildings) counts.set(b.id, (counts.get(b.id) ?? 0) + 1);
+ return counts;
+}
+
+function countPlacement(placement: Placement): Map {
+ const counts = new Map();
+ for (const b of placement) {
+ if (b !== null) counts.set(b.id, (counts.get(b.id) ?? 0) + 1);
+ }
+ return counts;
+}
+
+function sameCounts(a: Map, b: Map): boolean {
+ if (a.size !== b.size) return false;
+ for (const [id, n] of a) if (b.get(id) !== n) return false;
+ return true;
+}
+
+/**
+ * Places a fixed multiset of buildings, then improves the ARRANGEMENT only.
+ *
+ * Starting from `reference` (the best layout found so far), tiles are converted
+ * to match the target counts, choosing at each step the change that costs least
+ * power. The result is then improved with swaps, which move buildings around
+ * without changing what is on the board — so every candidate keeps the target's
+ * global heat/cooling budget and the search is over arrangements alone.
+ */
+export async function arrangeComposition(
+ composition: EffectiveBuilding[],
+ reference: Placement,
+ ctx: IslandContext,
+ deadlineMs: number,
+ pacer: Pacer,
+): Promise {
+ if (composition.length > ctx.n) return null;
+
+ const wanted = countById(composition);
+ const byId = new Map();
+ for (const b of composition) byId.set(b.id, b);
+ const current = reference.slice();
+
+ // 1. Reconcile counts with the target, cheapest change first.
+ while (performance.now() < deadlineMs && !pacer.stopRequested) {
+ const have = countPlacement(current);
+ const surplus = new Set();
+ for (const [id, n] of have) {
+ if (n > (wanted.get(id) ?? 0)) surplus.add(id);
+ }
+
+ let targetId: string | null = null;
+ for (const [id, n] of wanted) {
+ if (n > (have.get(id) ?? 0)) {
+ targetId = id;
+ break;
+ }
+ }
+
+ let bestMove = -1;
+ let bestPower = -1.0;
+
+ if (targetId === null) {
+ // Too many buildings overall: drop the least useful surplus tile.
+ if (surplus.size === 0) break;
+ for (let tile = 0; tile < ctx.n; tile++) {
+ const held = current[tile];
+ if (held === null || !surplus.has(held.id)) continue;
+ current[tile] = null;
+ const power = simulateIsland(current, ctx).totalPower;
+ put(current, ctx, tile, held);
+ if (power > bestPower) {
+ bestPower = power;
+ bestMove = tile;
+ }
+ }
+ if (bestMove < 0) break;
+ current[bestMove] = null;
+ } else {
+ const building = byId.get(targetId)!;
+ for (let tile = 0; tile < ctx.n; tile++) {
+ if (current[tile] !== null) continue;
+ put(current, ctx, tile, building);
+ const power = simulateIsland(current, ctx).totalPower;
+ current[tile] = null;
+ if (power > bestPower) {
+ bestPower = power;
+ bestMove = tile;
+ }
+ }
+ if (bestMove < 0) {
+ for (let tile = 0; tile < ctx.n; tile++) {
+ const held = current[tile];
+ if (held === null || !surplus.has(held.id)) continue;
+ put(current, ctx, tile, building);
+ const power = simulateIsland(current, ctx).totalPower;
+ put(current, ctx, tile, held);
+ if (power > bestPower) {
+ bestPower = power;
+ bestMove = tile;
+ }
+ }
+ }
+ if (bestMove < 0) return null;
+ put(current, ctx, bestMove, building);
+ }
+
+ const now = performance.now();
+ if (pacer.due(now)) await pacer.pump(now);
+ }
+
+ if (!sameCounts(countPlacement(current), wanted)) return null;
+
+ // 2. Improve the arrangement with swaps, which preserve the composition.
+ // Only nearby pairs are tried: swapping two buildings changes the layout
+ // solely through who they are adjacent to, so a swap between opposite ends of
+ // the island is two independent local changes and is better found as two
+ // separate moves. Restricting to Chebyshev distance 2 cuts the pairs to a
+ // fraction of all-pairs on a large island.
+ const occupied: number[] = [];
+ for (let tile = 0; tile < ctx.n; tile++) {
+ if (current[tile] !== null) occupied.push(tile);
+ }
+ // Plain ascending (x, y) order, which is NOT the tile index order
+ // (ascending X, DESCENDING Y). The fixtures pin this pairing exactly.
+ occupied.sort((a, b) => ctx.xs[a] - ctx.xs[b] || ctx.ys[a] - ctx.ys[b]);
+
+ const pairs: [number, number][] = [];
+ for (let i = 0; i < occupied.length; i++) {
+ for (let j = i + 1; j < occupied.length; j++) {
+ if (chebyshev(ctx, occupied[i], occupied[j]) <= SWAP_RADIUS) {
+ pairs.push([occupied[i], occupied[j]]);
+ }
+ }
+ }
+
+ while (performance.now() < deadlineMs && !pacer.stopRequested) {
+ const basePower = simulateIsland(current, ctx).totalPower;
+ let bestGain = 0.0;
+ let bestSwap: [number, number] | null = null;
+
+ for (let index = 0; index < pairs.length; index++) {
+ if ((index & 63) === 0) {
+ const now = performance.now();
+ if (now >= deadlineMs) break;
+ if (pacer.due(now)) {
+ await pacer.pump(now);
+ if (pacer.stopRequested) break;
+ }
+ }
+
+ const [a, b] = pairs[index];
+ const valA = current[a];
+ const valB = current[b];
+ // Swapping identical buildings changes nothing — asked in each tile's own
+ // units, so two scale classes holding the same roster entry still count
+ // as identical. See `ratedFor`.
+ if (ratedFor(ctx, a, valB) === valA && ratedFor(ctx, b, valA) === valB) {
+ continue;
+ }
+
+ put(current, ctx, a, valB);
+ put(current, ctx, b, valA);
+
+ const trial = simulateIsland(current, ctx);
+ if (
+ trial.totalPower - basePower > bestGain &&
+ !anyOfflineProducer(current, trial.placements) &&
+ planHolds(current, ctx)
+ ) {
+ bestGain = trial.totalPower - basePower;
+ bestSwap = [a, b];
+ }
+
+ put(current, ctx, a, valA);
+ put(current, ctx, b, valB);
+ }
+
+ if (bestSwap === null) break;
+ const [a, b] = bestSwap;
+ const held = current[a];
+ put(current, ctx, a, current[b]);
+ put(current, ctx, b, held);
+ }
+
+ return current;
+}
diff --git a/packages/solver/src/solver/search/placement.ts b/packages/solver/src/solver/search/placement.ts
new file mode 100644
index 0000000..31467ee
--- /dev/null
+++ b/packages/solver/src/solver/search/placement.ts
@@ -0,0 +1,111 @@
+import type { IslandContext } from "../context";
+import type { SimPlacedBuilding } from "../simulate";
+import type { EffectiveBuilding, PlacedBuilding, Placement } from "../types";
+
+/**
+ * The placement primitives every stage of the search shares: writing a
+ * building onto a tile in that tile's units, the cheap geometry the moves are
+ * built from, and turning simulation rows into the rows a result carries.
+ */
+
+/** Max Chebyshev distance between two tiles considered for an arrangement swap. */
+export const SWAP_RADIUS = 2;
+
+export function emptyPlacement(n: number): Placement {
+ return new Array(n).fill(null);
+}
+
+/**
+ * Writes a building onto a tile, rated for that tile.
+ *
+ * The one way a building enters a placement, so that a terrain bonus cannot be
+ * missed at one of three dozen sites — and a miss would be silent, since the
+ * layout would simply be worth less than it is. `ctx.rate` is the identity
+ * under every rule but a terrain bonus, and idempotent under that one, so this
+ * is equally correct for a fresh placement, a swap between two tiles, and the
+ * restore that undoes a rejected move.
+ *
+ * Clearing a tile stays a plain `= null`: a null carries no rating, so there is
+ * nothing to resolve and nothing to get wrong.
+ */
+export function put(
+ placement: Placement,
+ ctx: IslandContext,
+ tile: number,
+ building: EffectiveBuilding | null,
+): void {
+ placement[tile] = ratedFor(ctx, tile, building);
+}
+
+/**
+ * Exactly what `put` would write onto `tile` — the building as that tile rates
+ * it, or `null`.
+ *
+ * This is how a move asks "would writing this change anything?", and it has to
+ * be asked in the tile's own units: a placement holds RATED buildings, so
+ * comparing a plain roster entry against one is never equal on a scaled tile,
+ * and the guard that should have skipped the move instead writes back an
+ * identical object, runs a full `simulateIsland`, and hands `accept` a delta of
+ * zero — which it "accepts". No number moves, and the walk quietly spends a
+ * slice of a budget whose only product is steps per second.
+ *
+ * The comparison is exact rather than approximate because `ctx.rate` caches one
+ * object per (scale, roster entry) pair, so two tiles of the same scale holding
+ * the same building hold the *same* object — and it is cheap for the same
+ * reason: a lookup, not a multiply.
+ */
+export function ratedFor(
+ ctx: IslandContext,
+ tile: number,
+ building: EffectiveBuilding | null,
+): EffectiveBuilding | null {
+ return building === null ? null : ctx.rate(tile, building);
+}
+
+export function copyInto(dst: Placement, src: Placement): void {
+ for (let i = 0; i < dst.length; i++) dst[i] = src[i];
+}
+
+export function isReactor(b: EffectiveBuilding): boolean {
+ return b.type === "reactor";
+}
+
+export function isDirectProducer(b: EffectiveBuilding): boolean {
+ return b.type === "direct_producer";
+}
+
+export function chebyshev(ctx: IslandContext, a: number, b: number): number {
+ return Math.max(
+ Math.abs(ctx.xs[a] - ctx.xs[b]),
+ Math.abs(ctx.ys[a] - ctx.ys[b]),
+ );
+}
+
+/** The tile's Chebyshev-adjacent buildable neighbors that are still unclaimed. */
+export function availableNeighbors(
+ tile: number,
+ available: Uint8Array,
+ ctx: IslandContext,
+): number[] {
+ const out: number[] = [];
+ const nb = ctx.neighbors[tile];
+ for (let k = 0; k < nb.length; k++) {
+ if (available[nb[k]]) out.push(nb[k]);
+ }
+ return out;
+}
+
+export function toPlacedBuildings(rows: SimPlacedBuilding[]): PlacedBuilding[] {
+ return rows.map((r) => ({
+ x: r.x,
+ y: r.y,
+ buildingId: r.buildingId,
+ baseValue: r.baseValue,
+ powerGenerated: r.powerGenerated,
+ heatProduced: r.heatProduced,
+ heatConsumed: r.heatConsumed,
+ wasteHeatGenerated: r.wasteHeatGenerated,
+ coolingProvided: r.coolingProvided,
+ coolingReceived: r.coolingReceived,
+ }));
+}
diff --git a/packages/solver/src/solver/search/pooledCoolers.ts b/packages/solver/src/solver/search/pooledCoolers.ts
new file mode 100644
index 0000000..608a44d
--- /dev/null
+++ b/packages/solver/src/solver/search/pooledCoolers.ts
@@ -0,0 +1,405 @@
+import type { IslandContext } from "../context";
+import { type SimPlacedBuilding, simulateIsland } from "../simulate";
+import type { EffectiveBuilding, Placement } from "../types";
+import {
+ chebyshev,
+ copyInto,
+ emptyPlacement,
+ isReactor,
+ put,
+ ratedFor,
+ SWAP_RADIUS,
+} from "./placement";
+import { covers } from "./rightSize";
+import { anyOfflineProducer, planHolds } from "./stability";
+
+/**
+ * Under a shared cooling pool only: gathering the coolers the search scattered,
+ * after moving hubs off the smaller landmasses so those can hold them.
+ */
+
+/**
+ * How long `gatherPooledCoolers` may spend, per solve on the primary layout and
+ * once more shared across its alternates. It runs after the search's deadline,
+ * so this is time added to a run. Moving a cooler onto an empty tile costs
+ * nothing. The cap is for the cooler-to-producer swaps, which mostly fail and
+ * cost a simulation each. On the shipped maps one layout finishes in 15-110ms.
+ */
+export const GATHER_BUDGET_MS = 150;
+
+/**
+ * How long `evacuateMinorLandmasses` may spend, on top of `GATHER_BUDGET_MS`
+ * and taken first. Spent only on a board with producers on more than one
+ * landmass, and the pass stops as soon as a hub is placed — moving a
+ * 15-producer hub costs about 170ms on a desktop. The cap is sized for a phone,
+ * several times slower, where a tight one would make the pass silently do
+ * nothing; a second is small beside a 10-30s attempt. Out of time, the layout
+ * stays as it was.
+ */
+export const EVACUATE_BUDGET_MS = 1000;
+
+/** Chebyshev radius of the patch `gatherPooledCoolers` anchors on. */
+const GATHER_ANCHOR_RADIUS = 2;
+
+/** Each tile's landmass, numbered from 0, and how many there are. */
+function landmasses(ctx: IslandContext): {
+ component: Int32Array;
+ count: number;
+} {
+ const component = new Int32Array(ctx.n).fill(-1);
+ let count = 0;
+ for (let start = 0; start < ctx.n; start++) {
+ if (component[start] >= 0) continue;
+ const id = count++;
+ const stack = [start];
+ component[start] = id;
+ while (stack.length > 0) {
+ const nb = ctx.neighbors[stack.pop()!];
+ for (let k = 0; k < nb.length; k++) {
+ if (component[nb[k]] < 0) {
+ component[nb[k]] = id;
+ stack.push(nb[k]);
+ }
+ }
+ }
+ }
+ return { component, count };
+}
+
+/**
+ * Under a shared cooling pool, moves the hubs off every landmass but the main
+ * one, so that landmass can be given to the coolers.
+ *
+ * `gatherPooledCoolers` moves one tile at a time, and that cannot move a hub:
+ * a generator taken from its reactors loses power at every step of the way, so
+ * the hub on a small second landmass stays there and its coolers stay with it.
+ * This moves a landmass's producers all at once instead. Each takes a tile on
+ * the main landmass, the one holding the most producers, and the cooler it
+ * displaces goes to one of the tiles the producers left — so the pool's total
+ * and the board's composition never change, and the landmass comes out
+ * producer-free for the sweep to fill.
+ *
+ * The producers are placed greedily, whichever of them is worth the most power
+ * on whichever free tile goes next, and then the main landmass is swapped
+ * into shape until power is back (`relocateHub`). A landmass is evacuated only if the result
+ * holds the power floor, keeps every producer online and passes the upgrade
+ * plan; otherwise the layout is left exactly as it was. Smallest first, since a
+ * hub of a few producers is the likeliest to fit.
+ */
+function evacuateMinorLandmasses(
+ current: Placement,
+ ctx: IslandContext,
+ component: Int32Array,
+ count: number,
+ floor: number,
+ deadlineMs: number,
+): void {
+ const producersOn = new Array(count).fill(0);
+ for (let t = 0; t < ctx.n; t++) {
+ const b = current[t];
+ if (b !== null && b.type !== "cooler") producersOn[component[t]]++;
+ }
+ let main = 0;
+ for (let c = 1; c < count; c++)
+ if (producersOn[c] > producersOn[main]) main = c;
+
+ const minors: number[] = [];
+ for (let c = 0; c < count; c++)
+ if (c !== main && producersOn[c] > 0) minors.push(c);
+ minors.sort((a, b) => producersOn[a] - producersOn[b] || a - b);
+
+ const trial = current.slice();
+ for (const minor of minors) {
+ if (performance.now() >= deadlineMs) return;
+ copyInto(trial, current);
+ if (relocateHub(trial, ctx, component, minor, main, floor, deadlineMs))
+ copyInto(current, trial);
+ }
+}
+
+/**
+ * Moves every producer on `minor` onto `main`, in place. True when the result
+ * is a layout `evacuateMinorLandmasses` may keep; `trial` is junk otherwise.
+ */
+function relocateHub(
+ trial: Placement,
+ ctx: IslandContext,
+ component: Int32Array,
+ minor: number,
+ main: number,
+ floor: number,
+ deadlineMs: number,
+): boolean {
+ const movers: EffectiveBuilding[] = [];
+ const vacated: number[] = [];
+ for (let t = 0; t < ctx.n; t++) {
+ const b = trial[t];
+ if (component[t] !== minor || b === null || b.type === "cooler") continue;
+ movers.push(b);
+ vacated.push(t);
+ trial[t] = null;
+ }
+
+ // A tile on the main landmass a producer may take: empty, or a cooler that
+ // can step across to the landmass being emptied.
+ const free = (t: number): boolean =>
+ component[t] === main && (trial[t] === null || trial[t]!.type === "cooler");
+ const freeAround = (t: number): number => {
+ let n = 0;
+ for (const u of ctx.neighbors[t]) if (free(u)) n++;
+ return n;
+ };
+ // A reactor makes power only through a generator beside it, so a tile with
+ // none is not worth simulating.
+ const besideGenerator = (t: number): boolean => {
+ for (const u of ctx.neighbors[t])
+ if (trial[u] !== null && trial[u]!.type === "generator") return true;
+ return false;
+ };
+
+ let nextVacated = 0;
+ while (movers.length > 0) {
+ if (performance.now() >= deadlineMs) return false;
+
+ let bestPower = -Infinity;
+ let bestRoom = -1;
+ let bestTile = -1;
+ let bestMover = -1;
+ // A reactor beside no generator is still placed when nothing better is
+ // left — a generator that took the strongest tile may have taken the last
+ // free one beside it too — and the swap descent below moves it into place.
+ for (const anywhere of [false, true]) {
+ const tried = new Set();
+ for (let m = 0; m < movers.length; m++) {
+ const b = movers[m];
+ if (tried.has(b.id)) continue;
+ tried.add(b.id);
+ for (const t of ctx.tiles) {
+ if (!free(t)) continue;
+ if (!anywhere && isReactor(b) && !besideGenerator(t)) continue;
+ const held = trial[t];
+ put(trial, ctx, t, b);
+ const power = simulateIsland(trial, ctx).totalPower;
+ trial[t] = held;
+ // On a tie, the tile with the most room left around it, so a
+ // generator lands where its reactors can follow.
+ const room = freeAround(t);
+ if (power > bestPower || (power === bestPower && room > bestRoom)) {
+ bestPower = power;
+ bestRoom = room;
+ bestTile = t;
+ bestMover = m;
+ }
+ }
+ }
+ if (bestTile >= 0) break;
+ }
+ if (bestTile < 0) return false;
+
+ const displaced = trial[bestTile];
+ put(trial, ctx, bestTile, movers[bestMover]);
+ // The displaced cooler crosses over at once, so the pool is whole while
+ // the rest of the hub is placed.
+ if (displaced !== null) put(trial, ctx, vacated[nextVacated++], displaced);
+ movers.splice(bestMover, 1);
+ }
+
+ // The greedy order cannot see that a reactor placed early sits on the tile a
+ // later generator wanted, and the hubs already on the main landmass were
+ // built around a board that had no room for these. So: a swap descent over
+ // the whole landmass, nearby pairs only (as `arrangeComposition` does), first
+ // gain taken, until the floor is met. Swaps never change what is on the
+ // board, and the emptied landmass is not in the pairs, so nothing returns to
+ // it. On a cleared Entropy Isles under Cryo the greedy reaches 98.5% of the
+ // floor and one pass of this closes the rest.
+ const onMain: number[] = [];
+ for (const t of ctx.tiles) if (component[t] === main) onMain.push(t);
+ const pairs: [number, number][] = [];
+ for (let i = 0; i < onMain.length; i++)
+ for (let j = i + 1; j < onMain.length; j++)
+ if (chebyshev(ctx, onMain[i], onMain[j]) <= SWAP_RADIUS)
+ pairs.push([onMain[i], onMain[j]]);
+
+ const producer = (b: EffectiveBuilding | null): boolean =>
+ b !== null && b.type !== "cooler";
+ let power = simulateIsland(trial, ctx).totalPower;
+ let improved = true;
+ while (improved && !covers(power, floor)) {
+ improved = false;
+ for (const [a, b] of pairs) {
+ if (performance.now() >= deadlineMs) return false;
+ const va = trial[a];
+ const vb = trial[b];
+ // Coolers and empty tiles are interchangeable under the pool.
+ if (!producer(va) && !producer(vb)) continue;
+ if (ratedFor(ctx, a, vb) === va) continue;
+ put(trial, ctx, a, vb);
+ put(trial, ctx, b, va);
+ const swapped = simulateIsland(trial, ctx).totalPower;
+ if (swapped > power) {
+ power = swapped;
+ improved = true;
+ if (covers(power, floor)) break;
+ continue;
+ }
+ put(trial, ctx, a, va);
+ put(trial, ctx, b, vb);
+ }
+ }
+
+ const final = simulateIsland(trial, ctx);
+ return (
+ covers(final.totalPower, floor) &&
+ !anyOfflineProducer(trial, final.placements) &&
+ planHolds(trial, ctx)
+ );
+}
+
+/**
+ * Under a shared cooling pool, pulls the coolers into one area of the board
+ * and onto the small islands no producer is using.
+ *
+ * Under the pool a cooler's tile does not matter. Every cooler pays into one
+ * figure (`simulateIsland`), so power cannot tell a cooler in the middle of the
+ * hubs from one at the edge. The walk leaves coolers wherever a hub left a gap,
+ * scattered across the landmass, and that board is harder to read and to build
+ * than the same layout with its coolers together.
+ *
+ * Like `downgradeOversized`, this cannot find a better layout. It only moves
+ * coolers, swapping each with a tile nearer the anchor. Moving onto an empty
+ * tile always holds, because the pool's total and every producer are
+ * untouched. A swap with a producer moves that producer into the cooler's old
+ * tile, so it is re-simulated and kept only if power holds, every producer
+ * stays online and the upgrade plan still runs.
+ *
+ * The anchor is the tile with the most coolers within `GATHER_ANCHOR_RADIUS`,
+ * so the coolers gather on the patch where most of them already stand. Anchoring
+ * on the coolers' centroid was measured too: it put the anchor among the hubs,
+ * where a swap rarely holds, and left more clusters on maps 1 and 8. A tile on a
+ * landmass with no producer is a scrap nothing else can use, and counts as
+ * nearer than any tile on a working landmass, so empty scraps fill first. Every
+ * accepted move strictly lowers the coolers' summed distance, so the sweep
+ * terminates; the deadline only bounds how far it gets.
+ */
+export function gatherPooledCoolers(
+ rows: SimPlacedBuilding[],
+ power: number,
+ effectiveBuildings: EffectiveBuilding[],
+ ctx: IslandContext,
+ deadlineMs: number,
+ evacuateDeadlineMs = Math.min(
+ deadlineMs,
+ performance.now() + EVACUATE_BUDGET_MS,
+ ),
+): { rows: SimPlacedBuilding[]; power: number } {
+ if (ctx.anomaly.rule !== "shared_cooling" || rows.length === 0)
+ return { rows, power };
+
+ const byId = new Map(effectiveBuildings.map((b) => [b.id, b]));
+ const current = emptyPlacement(ctx.n);
+ for (const row of rows) {
+ const building = byId.get(row.buildingId);
+ if (building === undefined) return { rows, power };
+ put(current, ctx, row.idx, building);
+ }
+
+ const { component, count } = landmasses(ctx);
+ // The callers' deadline covers both budgets; capping the evacuation at its
+ // own (the default) leaves the sweep at least `GATHER_BUDGET_MS`.
+ evacuateMinorLandmasses(
+ current,
+ ctx,
+ component,
+ count,
+ power,
+ evacuateDeadlineMs,
+ );
+
+ // Which landmasses hold a producer, read after the evacuation so a landmass
+ // it emptied fills with coolers first, like any other scrap.
+ const working = new Array(count).fill(false);
+ for (let t = 0; t < ctx.n; t++) {
+ const b = current[t];
+ if (b !== null && b.type !== "cooler") working[component[t]] = true;
+ }
+
+ const movable = (t: number): boolean => {
+ const b = current[t];
+ return b !== null && b.type === "cooler" && working[component[t]];
+ };
+ const placed: number[] = [];
+ for (let t = 0; t < ctx.n; t++) if (movable(t)) placed.push(t);
+ if (placed.length === 0) return { rows, power };
+
+ let anchor = -1;
+ let densest = -1;
+ for (let t = 0; t < ctx.n; t++) {
+ if (!working[component[t]]) continue;
+ let near = 0;
+ for (const u of placed)
+ if (chebyshev(ctx, t, u) <= GATHER_ANCHOR_RADIUS) near++;
+ if (near > densest) {
+ densest = near;
+ anchor = t;
+ }
+ }
+
+ /** Squared distance to the anchor; a scrap tile is nearer than any. */
+ const distance = new Float64Array(ctx.n);
+ for (let t = 0; t < ctx.n; t++) {
+ distance[t] = working[component[t]]
+ ? (ctx.xs[t] - ctx.xs[anchor]) ** 2 + (ctx.ys[t] - ctx.ys[anchor]) ** 2
+ : -1;
+ }
+ // Nearest first, ascending index on a tie, so the result depends only on the
+ // board.
+ const byDistance = Array.from(ctx.tiles).sort(
+ (a, b) => distance[a] - distance[b] || a - b,
+ );
+
+ const floor = power;
+ let changed = true;
+ while (changed && performance.now() < deadlineMs) {
+ changed = false;
+ // Farthest cooler first: it has the most to gain from a move.
+ const coolers: number[] = [];
+ for (let t = 0; t < ctx.n; t++) if (movable(t)) coolers.push(t);
+ coolers.sort((a, b) => distance[b] - distance[a] || a - b);
+
+ for (const from of coolers) {
+ if (performance.now() >= deadlineMs) break;
+ const cooler = current[from]!;
+ for (const to of byDistance) {
+ if (distance[to] >= distance[from]) break;
+ const other = current[to];
+ if (other !== null && other.type === "cooler") continue;
+
+ put(current, ctx, to, cooler);
+ put(current, ctx, from, other);
+ if (other === null) {
+ // The pool's total and every producer are unchanged.
+ changed = true;
+ break;
+ }
+ const trial = simulateIsland(current, ctx);
+ if (
+ covers(trial.totalPower, floor) &&
+ !anyOfflineProducer(current, trial.placements) &&
+ planHolds(current, ctx)
+ ) {
+ changed = true;
+ break;
+ }
+ put(current, ctx, from, cooler);
+ put(current, ctx, to, other);
+ if (performance.now() >= deadlineMs) break;
+ }
+ }
+ }
+
+ const final = simulateIsland(current, ctx);
+ // A move to an empty tile is exact, and a swap had to hold the floor, so a
+ // shortfall here would be a bug rather than a trade. Give the input back.
+ if (!covers(final.totalPower, floor)) return { rows, power };
+ return { rows: final.placements, power: final.totalPower };
+}
diff --git a/packages/solver/src/solver/search/repair.ts b/packages/solver/src/solver/search/repair.ts
new file mode 100644
index 0000000..4dbeda3
--- /dev/null
+++ b/packages/solver/src/solver/search/repair.ts
@@ -0,0 +1,261 @@
+import { EPS } from "../constants";
+import type { IslandContext } from "../context";
+import { type IslandProgress, Pacer } from "../pacer";
+import { type SimPlacedBuilding, simulateIsland } from "../simulate";
+import type { EffectiveBuilding, Placement } from "../types";
+import {
+ chebyshev,
+ isReactor,
+ put,
+ ratedFor,
+ toPlacedBuildings,
+} from "./placement";
+import { anyOfflineProducer, planHolds, stabilize } from "./stability";
+
+/**
+ * Local repair and pruning: steepest ascent over single-tile changes, and the
+ * closing sweep that drops dead weight from a finished layout.
+ */
+
+/** How many reactor tiers the local-repair pass considers per tile. */
+export const POLISH_REACTOR_TIERS = 3;
+/**
+ * How far from an applied repair move the cached gains are re-measured. A
+ * change on a tile is felt through its neighbours' supply and demand, so a
+ * tile two steps away shares a neighbour with it and one three steps away
+ * shares nothing — see `greedyPolish`.
+ */
+const POLISH_DIRTY_RADIUS = 2;
+
+/**
+ * The lean set of buildings worth trying on a tile during local repair.
+ *
+ * Generators and coolers jump 400x-4100x per tier, so only their top tier can
+ * ever appear in a good layout. Reactors only jump ~8x, which is small enough
+ * that a weaker one is genuinely useful for topping up a generator's spare heat
+ * capacity when there is leftover cooling, so a few reactor tiers are kept.
+ * Every extra candidate costs another full pass over the island.
+ */
+export function polishCandidates(
+ reactors: EffectiveBuilding[],
+ generators: EffectiveBuilding[],
+ coolers: EffectiveBuilding[],
+ directProducers: EffectiveBuilding[],
+): (EffectiveBuilding | null)[] {
+ const candidates: (EffectiveBuilding | null)[] = reactors.slice(
+ 0,
+ POLISH_REACTOR_TIERS,
+ );
+ if (generators.length > 0) candidates.push(generators[0]);
+ if (coolers.length > 0) candidates.push(coolers[0]);
+ if (directProducers.length > 0) candidates.push(directProducers[0]);
+ // Leaving the tile empty is a real option.
+ candidates.push(null);
+ return candidates;
+}
+
+/**
+ * Steepest-ascent repair: repeatedly applies the single-tile change that raises
+ * power the most, considering only stable layouts, until no single change
+ * helps.
+ *
+ * The annealing walk handles small islands well, but on a large, fully packed
+ * one nearly every single-tile change costs several percent, so the walk drifts
+ * downhill and rarely climbs back: measured on a 67-tile island, only 1
+ * evaluation in 26,000 beat its own starting layout, while 20 stable improving
+ * moves sat a single move away. A full sweep here costs |tiles| x |candidates|
+ * simulations and finds them directly.
+ */
+export async function greedyPolish(
+ placement: Placement,
+ ctx: IslandContext,
+ candidates: (EffectiveBuilding | null)[],
+ deadlineMs: number,
+ pacer: Pacer,
+): Promise<{ placement: Placement; power: number }> {
+ let current = placement.slice();
+ const sim = simulateIsland(current, ctx);
+ let bestPower = sim.totalPower;
+ if (anyOfflineProducer(current, sim.placements) || !planHolds(current, ctx)) {
+ const stabilized = stabilize(current, ctx);
+ current = stabilized.placement;
+ bestPower = stabilized.power;
+ }
+
+ // The gain of every (tile, candidate) change, measured against the layout
+ // as it stood when that tile was last swept. A full sweep after every
+ // accepted move was the whole cost of this stage — |tiles| x |candidates|
+ // simulations per move, 2-3s for one repair on the largest shipped islands
+ // — and nearly all of it re-measured changes far from the move, whose gain
+ // the move cannot have touched: a single-tile change is felt through its
+ // neighbours' supply and demand, so only tiles within `POLISH_DIRTY_RADIUS`
+ // of an applied move are swept again. The rest keep their figure, which is
+ // approximate once anything nearby has moved, so a cached winner is always
+ // re-simulated before it is applied and dropped back into the sweep if it
+ // no longer holds. Every applied change is therefore verified exactly;
+ // only the order the changes are found in can differ from a full sweep.
+ //
+ // Measured with the survey (`--runs 3 --seed 1`, every map and anomaly):
+ // at 15s per map it is a wash, every map inside the noise band; at 5s —
+ // where the polish share binds, as it does on a phone at any budget — Tidal
+ // gains 5-9% on six of the eight maps and Shadowspire under Singularity
+ // 16%, against one 83-tile island down 0.7%. One repair on the seed of the
+ // largest island went from 950ms to 180ms (`npm run profile`).
+ const nCandidates = candidates.length;
+ const gains = new Float64Array(ctx.n * nCandidates).fill(-Infinity);
+ const dirty = new Uint8Array(ctx.n).fill(1);
+
+ while (performance.now() < deadlineMs && !pacer.stopRequested) {
+ let cut = false;
+ for (let tile = 0; tile < ctx.n; tile++) {
+ if (dirty[tile] === 0) continue;
+ const now = performance.now();
+ if (now >= deadlineMs) {
+ cut = true;
+ break;
+ }
+ if (pacer.due(now)) {
+ await pacer.pump(now, () => islandProgress(current, ctx));
+ if (pacer.stopRequested) {
+ cut = true;
+ break;
+ }
+ }
+
+ dirty[tile] = 0;
+ const held = current[tile];
+ for (let k = 0; k < nCandidates; k++) {
+ const building = candidates[k];
+ let gain = -Infinity;
+ // The candidate list is the plain roster, `held` is rated for this tile:
+ // asked the wrong way round, a scaled tile re-simulates its own layout
+ // once per sweep for nothing. See `ratedFor`.
+ if (ratedFor(ctx, tile, building) !== held) {
+ put(current, ctx, tile, building);
+ const trial = simulateIsland(current, ctx);
+ if (
+ trial.totalPower - bestPower > 0 &&
+ !anyOfflineProducer(current, trial.placements) &&
+ planHolds(current, ctx)
+ ) {
+ gain = trial.totalPower - bestPower;
+ }
+ put(current, ctx, tile, held);
+ }
+ gains[tile * nCandidates + k] = gain;
+ }
+ }
+ if (cut) break;
+
+ let bestGain = 0.0;
+ let bestTile = -1;
+ let bestCandidate = -1;
+ for (let i = 0; i < gains.length; i++) {
+ if (gains[i] > bestGain) {
+ bestGain = gains[i];
+ bestTile = (i / nCandidates) | 0;
+ bestCandidate = i - bestTile * nCandidates;
+ }
+ }
+ if (bestTile < 0) break;
+
+ // Exact, whether the figure was fresh or cached: the one simulation that
+ // decides what this stage hands on.
+ const held = current[bestTile];
+ put(current, ctx, bestTile, candidates[bestCandidate]);
+ const trial = simulateIsland(current, ctx);
+ if (
+ trial.totalPower - bestPower > 0 &&
+ !anyOfflineProducer(current, trial.placements) &&
+ planHolds(current, ctx)
+ ) {
+ bestPower = trial.totalPower;
+ for (let tile = 0; tile < ctx.n; tile++) {
+ if (chebyshev(ctx, tile, bestTile) <= POLISH_DIRTY_RADIUS)
+ dirty[tile] = 1;
+ }
+ } else {
+ // Stale: the layout moved under it. Back to the sweep for a fresh figure.
+ put(current, ctx, bestTile, held);
+ dirty[bestTile] = 1;
+ }
+ }
+
+ return { placement: current, power: bestPower };
+}
+
+/**
+ * Post-search cleanup:
+ * 1. Drops every producer generating 0 power, repeating until none remain.
+ * 2. Sequentially tests removing reactors/coolers to eliminate redundant
+ * support structures.
+ * 3. Guarantees a stable layout — every returned producer is online — with a
+ * minimal active tile footprint.
+ *
+ * Step 1 is unconditional: an overheating building is never acceptable, even
+ * when keeping it would score higher (see `stabilize`). Step 2 is guarded,
+ * since removing support can only ever cost power.
+ */
+export function pruneDeadWeight(
+ placement: Placement,
+ ctx: IslandContext,
+): { rows: SimPlacedBuilding[]; power: number } {
+ let anyOccupied = false;
+ for (let tile = 0; tile < ctx.n; tile++) {
+ if (placement[tile] !== null) {
+ anyOccupied = true;
+ break;
+ }
+ }
+ if (!anyOccupied) return { rows: [], power: 0.0 };
+
+ // 1. Remove producers that generate no power (cascading).
+ const stabilized = stabilize(placement, ctx);
+ const current = stabilized.placement;
+ let bestPower = stabilized.power;
+ let rows = stabilized.rows;
+
+ // 2. Greedy backward sweep: test removing support buildings (reactors & coolers).
+ const support: number[] = [];
+ for (let tile = 0; tile < ctx.n; tile++) {
+ const b = current[tile];
+ if (b === null) continue;
+ if (b.type === "cooler" || isReactor(b)) support.push(tile);
+ }
+
+ for (const tile of support) {
+ const held = current[tile];
+ if (held === null) continue;
+
+ current[tile] = null;
+ const trial = simulateIsland(current, ctx);
+
+ // Power alone is not enough to accept a removal. Dropping a reactor can
+ // starve one generator to zero while handing its heat to another that had
+ // spare capacity, leaving total power unchanged but stranding an idle
+ // generator on the board.
+ if (
+ trial.totalPower >= bestPower - EPS &&
+ !anyOfflineProducer(current, trial.placements) &&
+ planHolds(current, ctx)
+ ) {
+ bestPower = trial.totalPower;
+ rows = trial.placements;
+ } else {
+ put(current, ctx, tile, held);
+ }
+ }
+
+ return { rows, power: bestPower };
+}
+
+export function islandProgress(
+ placement: Placement,
+ ctx: IslandContext,
+): IslandProgress {
+ const pruned = pruneDeadWeight(placement, ctx);
+ return {
+ placements: toPlacedBuildings(pruned.rows),
+ powerOutput: pruned.power,
+ };
+}
diff --git a/packages/solver/src/solver/search/rightSize.ts b/packages/solver/src/solver/search/rightSize.ts
new file mode 100644
index 0000000..e5e5cc8
--- /dev/null
+++ b/packages/solver/src/solver/search/rightSize.ts
@@ -0,0 +1,216 @@
+import type { IslandContext } from "../context";
+import { wasteIsCovered } from "../physics";
+import { type SimPlacedBuilding, simulateIsland } from "../simulate";
+import type { EffectiveBuilding, Placement } from "../types";
+import { emptyPlacement, isReactor, put } from "./placement";
+import { anyOfflineProducer, planHolds } from "./stability";
+
+/**
+ * Right-sizing the finished layout: every building swapped for the smallest
+ * tier that still carries the load the layout gives it.
+ */
+
+/**
+ * The roles a finished layout can be re-tiered in. A direct producer always
+ * runs flat out on its own, so there is no slack in one to give back — a
+ * smaller tier is simply less power, and the power guard below would reject it
+ * anyway. The other three are sized against a load the finished layout fixes,
+ * and any capacity above that load is money spent on a building that never
+ * uses it.
+ */
+export const DOWNGRADE_ROLES: ReadonlySet = new Set([
+ "cooler",
+ "reactor",
+ "generator",
+]);
+
+/**
+ * True when `capacity` meets `load` at the game's own relative tolerance.
+ *
+ * This is `wasteIsCovered`, whose parameters are named for the cooling case it
+ * was written for; the tolerance has to be relative here for the same reason it
+ * does there — at 1e18 one ULP is already ~1e2, so an absolute epsilon would
+ * reject a tier that is exactly big enough.
+ */
+export function covers(capacity: number, load: number): boolean {
+ return wasteIsCovered(load, capacity);
+}
+
+/**
+ * What a placed building is actually doing, in the units its tier is sized in.
+ *
+ * Cooling sent for a cooler, heat sent for a reactor, heat absorbed for a
+ * generator — the figure a replacement tier has to be able to match for the
+ * rest of the layout to keep behaving the same way.
+ */
+export function tileLoad(
+ building: EffectiveBuilding,
+ row: SimPlacedBuilding,
+): number {
+ if (building.type === "cooler") return row.coolingProvided;
+ if (isReactor(building)) return row.heatProduced;
+ return row.heatConsumed;
+}
+
+/**
+ * The capacity `candidate` would actually run at on `tile` in this layout —
+ * `tileLoad`'s units, so the two can be compared.
+ *
+ * `ctx.rate` answers this for every rule but one: a role isolation multiplier is
+ * a function of what a tile's NEIGHBOURS are, so `rate` is the identity under it
+ * and `simulateIsland` resolves it per layout through `rateIsolated` instead.
+ * That leaves right-sizing measuring a load rated by `isolated` against an
+ * unrated capacity: an isolated generator authored 320, rated 1280 (at the
+ * `isolated` the rule shipped with) and absorbing 300, was never offered the
+ * authored 120 tier that covers it at its own rating of 480, so the pass left
+ * 980 of intake nobody pays it to have — which is exactly the money it
+ * exists to hand back. In the other direction the plain figure is over-generous,
+ * and there the re-simulation below catches it, so only the waste escaped.
+ *
+ * The neighbour scan mirrors `simulateIsland`'s: it reads what a neighbour *is*,
+ * which the swap cannot change — the ladder is one role deep, so a re-tiered
+ * generator is still a generator and every tile's crowding survives the pass.
+ */
+function ratedCapacity(
+ ctx: IslandContext,
+ placement: Placement,
+ tile: number,
+ candidate: EffectiveBuilding,
+): number {
+ const isolation = ctx.isolation;
+ if (isolation === null || candidate.type !== isolation.role) {
+ return candidate.effectiveValue;
+ }
+
+ const neighbors = ctx.neighbors[tile];
+ let crowded = false;
+ for (let k = 0; k < neighbors.length; k++) {
+ const other = placement[neighbors[k]];
+ if (other !== null && other.type === isolation.role) {
+ crowded = true;
+ break;
+ }
+ }
+ return ctx.rateIsolated(candidate, crowded).effectiveValue;
+}
+
+/** Each downgradable role's roster entries, smallest capacity first. */
+export function downgradeTiers(
+ effectiveBuildings: EffectiveBuilding[],
+): Map {
+ const tiers = new Map();
+ for (const role of DOWNGRADE_ROLES) {
+ tiers.set(
+ role,
+ effectiveBuildings
+ .filter((b) => b.type === role)
+ .sort((a, b) => a.effectiveValue - b.effectiveValue),
+ );
+ }
+ return tiers;
+}
+
+/**
+ * Final pass: replace every building with the smallest tier that still does the
+ * job the finished layout gives it. Port of `_downgrade_oversized`.
+ *
+ * The search maximizes power, and power cannot tell a cooler running flat out
+ * from one running at 2%: both keep the same generators online, so both score
+ * identically and the search has no reason to prefer either. It therefore
+ * leaves the roster's top tier on tiles that need a twentieth of it, which
+ * costs the player real money for capacity that never runs. This is why the
+ * pass belongs after the search rather than inside it — only once the layout
+ * stops moving is the load on each tile settled enough to size against.
+ *
+ * Covering the tile's current load is what makes a candidate plausible, not
+ * what makes it safe: shrinking a supplier changes the FairShare split, and the
+ * split is what decides which producers clear their cooling. So every swap is
+ * re-simulated and accepted only under the test `pruneDeadWeight` applies to a
+ * removal — no power lost against the layout handed in, and no producer left
+ * offline. Power is defended against the figure this pass started from rather
+ * than against the running total, so a sequence of swaps cannot drift down one
+ * tolerance at a time.
+ *
+ * The sweep repeats to a fixpoint: downgrading a generator changes the waste
+ * its cooler has to absorb, which can free a cooler this sweep already walked
+ * past. It terminates because every accepted swap strictly lowers one tile's
+ * capacity and the ladders are finite.
+ *
+ * Tiles are visited in ascending index order, which is the game's own spatial
+ * order — see `context.ts`.
+ */
+export function downgradeOversized(
+ rows: SimPlacedBuilding[],
+ power: number,
+ effectiveBuildings: EffectiveBuilding[],
+ ctx: IslandContext,
+): { rows: SimPlacedBuilding[]; power: number } {
+ if (rows.length === 0) return { rows, power };
+
+ const byId = new Map(effectiveBuildings.map((b) => [b.id, b]));
+ const current = emptyPlacement(ctx.n);
+ const occupied: number[] = [];
+ for (const row of rows) {
+ const building = byId.get(row.buildingId);
+ // Everything the search places comes from this roster; a layout holding
+ // anything else is not one this pass can reason about.
+ if (building === undefined) return { rows, power };
+ put(current, ctx, row.idx, building);
+ occupied.push(row.idx);
+ }
+ occupied.sort((a, b) => a - b);
+
+ const tiers = downgradeTiers(effectiveBuildings);
+ const rowAt = new Map(rows.map((r) => [r.idx, r]));
+ const floor = power;
+
+ for (let changed = true; changed;) {
+ changed = false;
+
+ for (const tile of occupied) {
+ const building = current[tile]!;
+ const ladder = tiers.get(building.type);
+ if (ladder === undefined || ladder.length === 0) continue;
+
+ const load = tileLoad(building, rowAt.get(tile)!);
+
+ for (const candidate of ladder) {
+ // Rated for this tile before it is compared to anything: `building` is
+ // what the tile is running and `load` is what the layout measured it
+ // doing, both in this tile's units, and the ladder is the plain roster.
+ // Ascending order survives the rating, since one tile scales every
+ // candidate by the same factor.
+ const rated = ctx.rate(tile, candidate);
+ // Ascending, so nothing smaller is left to try. Both sides are `rate`'s
+ // units, and one tile scales every candidate by the same factor, so the
+ // ladder's order survives whichever units this is asked in.
+ if (rated.effectiveValue >= building.effectiveValue) break;
+ // Covering the load is asked in the layout's units, which is `rate`'s
+ // plus whatever `simulateIsland` resolves per layout — see
+ // `ratedCapacity`. `load` came off a row it measured, so this is the one
+ // comparison here that cannot be made in the roster's units.
+ if (!covers(ratedCapacity(ctx, current, tile, rated), load)) continue;
+
+ put(current, ctx, tile, rated);
+ const trial = simulateIsland(current, ctx);
+
+ if (
+ covers(trial.totalPower, floor) &&
+ !anyOfflineProducer(current, trial.placements) &&
+ planHolds(current, ctx)
+ ) {
+ power = trial.totalPower;
+ rows = trial.placements;
+ rowAt.clear();
+ for (const r of rows) rowAt.set(r.idx, r);
+ changed = true;
+ break;
+ }
+
+ put(current, ctx, tile, building);
+ }
+ }
+ }
+
+ return { rows, power };
+}
diff --git a/packages/solver/src/solver/search/seed.ts b/packages/solver/src/solver/search/seed.ts
new file mode 100644
index 0000000..6e30108
--- /dev/null
+++ b/packages/solver/src/solver/search/seed.ts
@@ -0,0 +1,896 @@
+import { CHEBYSHEV_DIRECTIONS, EPS } from "../constants";
+import type { IslandContext } from "../context";
+import {
+ generatorPowerAndWaste,
+ generatorWasteRatio,
+ wasteIsCovered,
+} from "../physics";
+import { Rng } from "../rng";
+import { simulateIsland } from "../simulate";
+import type { EffectiveBuilding, Placement } from "../types";
+import {
+ availableNeighbors,
+ chebyshev,
+ emptyPlacement,
+ put,
+} from "./placement";
+
+/**
+ * Seed construction: self-sufficient hubs claimed greedily by power per tile,
+ * in three tile orders, the strongest of which starts the search.
+ */
+
+const MAX_TOP_REACTOR_COPIES = 3;
+const MAX_SECOND_REACTOR_COPIES = 3;
+/**
+ * The cooling a pooled seed sizes its hubs against: cooling from nowhere, so
+ * that no hub ever needs a cooler beside it. It has to be `Infinity` and not
+ * merely large — a fit counts `ceil(waste / cooling)` coolers, and any finite
+ * figure makes that one, not none. `snapToAuthoredPrecision` passes it through
+ * and the pool's `factor` compares with `<`, so the scratch simulation the
+ * shared fit runs against it is sound. Never written onto a real placement.
+ */
+const POOL_COOLER_VALUE = Infinity;
+
+/** Scratch for `generatorPowerAndWaste`, reused for the same reason as in `simulate.ts`. */
+const conversion = { power: 0.0, waste: 0.0 };
+
+interface HubFit {
+ generator: EffectiveBuilding;
+ reactor: EffectiveBuilding;
+ r: number;
+ c: number;
+ power: number;
+ /** The waste the hub makes at that fill — what a pooled seed charges for. */
+ waste: number;
+}
+
+function bestHubFit(
+ availableSlots: number,
+ generators: EffectiveBuilding[],
+ reactors: EffectiveBuilding[],
+ cooler: EffectiveBuilding,
+): HubFit | null {
+ let best: HubFit | null = null;
+ for (const generator of generators) {
+ for (const reactor of reactors) {
+ const maxR = Math.min(
+ availableSlots,
+ Math.ceil(generator.effectiveValue / reactor.effectiveValue),
+ );
+ for (let r = 1; r <= maxR; r++) {
+ const hIn = Math.min(
+ generator.effectiveValue,
+ r * reactor.effectiveValue,
+ );
+ generatorPowerAndWaste(generator, hIn, conversion);
+ const waste = conversion.waste;
+ const c =
+ cooler.effectiveValue > 0
+ ? Math.ceil(waste / cooler.effectiveValue)
+ : 0;
+ if (r + c <= availableSlots) {
+ const power = conversion.power;
+ if (best === null || power > best.power) {
+ best = { generator, reactor, r, c, power, waste };
+ }
+ }
+ if (hIn >= generator.effectiveValue - EPS) break;
+ }
+ }
+ }
+ return best;
+}
+
+interface DpFit {
+ dp: EffectiveBuilding;
+ c: number;
+ power: number;
+ waste: number;
+}
+
+function bestDpFit(
+ availableSlots: number,
+ directProducers: EffectiveBuilding[],
+ cooler: EffectiveBuilding,
+): DpFit | null {
+ let best: DpFit | null = null;
+ for (const dp of directProducers) {
+ const waste = dp.waste;
+ const c =
+ cooler.effectiveValue > 0 ? Math.ceil(waste / cooler.effectiveValue) : 0;
+ if (c > availableSlots) continue;
+ const power = dp.energy;
+ if (best === null || power > best.power) best = { dp, c, power, waste };
+ }
+ return best;
+}
+
+/** `itertools.combinations` over a list, in the same lexicographic order. */
+function* combinations(pool: number[], k: number): Generator {
+ const n = pool.length;
+ if (k > n || k < 0) return;
+ const idx = new Array(k);
+ for (let i = 0; i < k; i++) idx[i] = i;
+ for (;;) {
+ const out = new Array(k);
+ for (let i = 0; i < k; i++) out[i] = pool[idx[i]];
+ yield out;
+
+ let i = k - 1;
+ while (i >= 0 && idx[i] === i + n - k) i--;
+ if (i < 0) return;
+ idx[i]++;
+ for (let j = i + 1; j < k; j++) idx[j] = idx[j - 1] + 1;
+ }
+}
+
+interface SharedFit {
+ power: number;
+ reactorTiles: number[];
+ reactorTiers: EffectiveBuilding[];
+ genPositions: number[];
+ coolerPositions: number[];
+ waste: number;
+}
+
+/**
+ * A multi-generator arrangement around one reactor tile, for rosters where a
+ * single top-tier reactor overfills a generator: the surplus heat is only
+ * usable if two or more generators share that reactor, which the plain hub fit
+ * can never express.
+ */
+function bestSharedReactorFit(
+ tile: number,
+ neighbors: number[],
+ reactors: EffectiveBuilding[],
+ generator: EffectiveBuilding,
+ cooler: EffectiveBuilding,
+ ctx: IslandContext,
+ scratch: Placement,
+ pooled = false,
+): SharedFit | null {
+ if (reactors.length === 0) return null;
+ const topReactor = reactors[0];
+ const secondReactor = reactors.length > 1 ? reactors[1] : null;
+
+ // Under a pool `cooler` is the phantom and `c` is 0, so the arrangement is
+ // simulated with no cooler beside it — and a coolerless board is offline.
+ // The pool is stood in for by the phantom on any tile outside the
+ // neighbourhood; where the island has none, the arrangement cannot be
+ // measured and is not offered.
+ let poolTile = -1;
+ if (pooled) {
+ const local = new Set(neighbors);
+ for (let t = 0; t < ctx.n; t++) {
+ if (t !== tile && !local.has(t)) {
+ poolTile = t;
+ break;
+ }
+ }
+ if (poolTile < 0) return null;
+ }
+
+ const connectivity = new Map();
+ for (const n of neighbors) {
+ let count = 0;
+ for (const m of neighbors) {
+ if (m !== n && chebyshev(ctx, n, m) <= 1) count++;
+ }
+ connectivity.set(n, count);
+ }
+ // Ascending tile index is ascending spatial key, so this is
+ // `(connectivity, spatial_key)` exactly.
+ const byConnectivityAsc = [...neighbors].sort(
+ (a, b) => connectivity.get(a)! - connectivity.get(b)! || a - b,
+ );
+
+ let best: SharedFit | null = null;
+ const maxK1 = Math.min(MAX_TOP_REACTOR_COPIES, 1 + neighbors.length);
+
+ for (let k1 = 1; k1 <= maxK1; k1++) {
+ const extraTopNeeded = k1 - 1;
+ const maxK2 =
+ secondReactor !== null
+ ? Math.min(MAX_SECOND_REACTOR_COPIES, neighbors.length - extraTopNeeded)
+ : 0;
+
+ for (let k2 = 0; k2 <= maxK2; k2++) {
+ const nExtraReactors = extraTopNeeded + k2;
+ if (nExtraReactors > neighbors.length) continue;
+
+ let totalValue = k1 * topReactor.effectiveValue;
+ if (k2 && secondReactor) totalValue += k2 * secondReactor.effectiveValue;
+
+ const g = Math.ceil(totalValue / generator.effectiveValue);
+ if (g < 2 || nExtraReactors + g + 1 > neighbors.length) continue;
+
+ const absorbed = Math.min(totalValue, g * generator.effectiveValue);
+ const wasteTotal = absorbed * generatorWasteRatio(generator);
+ const c =
+ cooler.effectiveValue > 0
+ ? Math.ceil(wasteTotal / cooler.effectiveValue)
+ : 0;
+ if (nExtraReactors + g + c > neighbors.length) continue;
+
+ const extraPositions = byConnectivityAsc.slice(0, nExtraReactors);
+ const extraTopPositions = extraPositions.slice(0, extraTopNeeded);
+ const extraSecondPositions = extraPositions.slice(
+ extraTopNeeded,
+ nExtraReactors,
+ );
+ const extraSet = new Set(extraPositions);
+ const pool = neighbors.filter((n) => !extraSet.has(n));
+
+ for (const genPositions of combinations(pool, g)) {
+ const genSet = new Set(genPositions);
+ const leftover = pool.filter((n) => !genSet.has(n));
+ leftover.sort((a, b) => {
+ let adjA = 0;
+ let adjB = 0;
+ for (const gpos of genPositions) {
+ if (chebyshev(ctx, a, gpos) <= 1) adjA++;
+ if (chebyshev(ctx, b, gpos) <= 1) adjB++;
+ }
+ return adjB - adjA || a - b;
+ });
+ const coolerPositions = leftover.slice(0, c);
+
+ const touched: number[] = [];
+ put(scratch, ctx, tile, topReactor);
+ touched.push(tile);
+ for (const n of extraTopPositions) {
+ put(scratch, ctx, n, topReactor);
+ touched.push(n);
+ }
+ for (const n of extraSecondPositions) {
+ put(scratch, ctx, n, secondReactor);
+ touched.push(n);
+ }
+ for (const n of genPositions) {
+ put(scratch, ctx, n, generator);
+ touched.push(n);
+ }
+ for (const n of coolerPositions) {
+ put(scratch, ctx, n, cooler);
+ touched.push(n);
+ }
+ if (pooled) {
+ put(scratch, ctx, poolTile, cooler);
+ touched.push(poolTile);
+ }
+
+ const { totalPower } = simulateIsland(scratch, ctx);
+ for (const n of touched) scratch[n] = null;
+
+ if (totalPower > EPS && (best === null || totalPower > best.power)) {
+ const reactorTiles = [
+ tile,
+ ...extraTopPositions,
+ ...extraSecondPositions,
+ ];
+ const reactorTiers: EffectiveBuilding[] = [
+ topReactor,
+ ...extraTopPositions.map(() => topReactor),
+ ...extraSecondPositions.map(() => secondReactor!),
+ ];
+ best = {
+ power: totalPower,
+ reactorTiles,
+ reactorTiers,
+ genPositions,
+ coolerPositions,
+ waste: wasteTotal,
+ };
+ }
+ }
+ }
+ }
+
+ return best;
+}
+
+/** A `SharedFit` with every tile spelled as a direction from its centre. */
+interface RelativeSharedFit {
+ power: number;
+ waste: number;
+ /** Direction index per reactor, or -1 for the centre tile itself. */
+ reactorDirections: number[];
+ reactorTiers: EffectiveBuilding[];
+ genDirections: number[];
+ coolerDirections: number[];
+}
+
+/**
+ * The shape of a tile's free neighbourhood: a bit per `CHEBYSHEV_DIRECTIONS`
+ * entry that holds a free tile, with the maps between tile and direction.
+ */
+function neighbourhoodShape(
+ ctx: IslandContext,
+ tile: number,
+ neighbors: number[],
+): {
+ mask: number;
+ directionOf: Map;
+ tileAt: Int32Array;
+} {
+ let mask = 0;
+ const directionOf = new Map();
+ const tileAt = new Int32Array(CHEBYSHEV_DIRECTIONS.length).fill(-1);
+ for (const n of neighbors) {
+ const dx = ctx.xs[n] - ctx.xs[tile];
+ const dy = ctx.ys[n] - ctx.ys[tile];
+ for (let d = 0; d < CHEBYSHEV_DIRECTIONS.length; d++) {
+ if (
+ CHEBYSHEV_DIRECTIONS[d][0] === dx &&
+ CHEBYSHEV_DIRECTIONS[d][1] === dy
+ ) {
+ mask |= 1 << d;
+ directionOf.set(n, d);
+ tileAt[d] = n;
+ break;
+ }
+ }
+ }
+ return { mask, directionOf, tileAt };
+}
+
+function toRelativeFit(
+ fit: SharedFit,
+ directionOf: Map,
+): RelativeSharedFit {
+ const direction = (t: number): number => directionOf.get(t) ?? -1;
+ return {
+ power: fit.power,
+ waste: fit.waste,
+ reactorDirections: fit.reactorTiles.map(direction),
+ reactorTiers: fit.reactorTiers,
+ genDirections: fit.genPositions.map(direction),
+ coolerDirections: fit.coolerPositions.map(direction),
+ };
+}
+
+function fromRelativeFit(
+ fit: RelativeSharedFit,
+ tile: number,
+ tileAt: Int32Array,
+): SharedFit {
+ const at = (d: number): number => (d < 0 ? tile : tileAt[d]);
+ return {
+ power: fit.power,
+ waste: fit.waste,
+ reactorTiles: fit.reactorDirections.map(at),
+ reactorTiers: fit.reactorTiers,
+ genPositions: fit.genDirections.map(at),
+ coolerPositions: fit.coolerDirections.map(at),
+ };
+}
+
+interface CandidateEntry {
+ /**
+ * Power PER TILE CLAIMED, which is what candidates are ranked by — not raw
+ * power. A candidate that spends more tiles (e.g. a multi-reactor "shared"
+ * setup) can have higher raw power than a compact one-reactor hub while
+ * actually being a worse use of the island's limited tiles: it wins a single
+ * greedy round yet leaves the *rest* of the island worse off, since those
+ * extra tiles are no longer available for other, more efficient hubs.
+ * Ranking by power/tile keeps the greedy choice locally optimal in the
+ * currency that is actually scarce.
+ */
+ powerPerTile: number;
+ kind: "hub" | "dp" | "shared";
+ tile: number;
+ neighbors: number[];
+ hub?: HubFit;
+ dp?: DpFit;
+ shared?: SharedFit;
+}
+
+function constructSeed(
+ buildableOrder: number[],
+ ctx: IslandContext,
+ reactors: EffectiveBuilding[],
+ generators: EffectiveBuilding[],
+ coolers: EffectiveBuilding[],
+ directProducers: EffectiveBuilding[],
+ deadlineMs: number,
+ pooled = false,
+ sizeByIsolation = true,
+): Placement {
+ const placement = emptyPlacement(ctx.n);
+ const available = new Uint8Array(ctx.n);
+ for (const tile of buildableOrder) available[tile] = 1;
+ let numAvailable = buildableOrder.length;
+
+ const topReactor = reactors.length > 0 ? reactors[0] : null;
+ const topGenerator = generators.length > 0 ? generators[0] : null;
+ const topCooler = coolers.length > 0 ? coolers[0] : null;
+
+ // Under a generator isolation a hub is sized with its generator at the
+ // ISOLATED rating, and no hub's generator is put beside another. Sized from
+ // the plain roster, a full-unlock hub is one ecto reactor filling one
+ // generator and the "shared" fit stands three generators round one reactor
+ // — every one of them crowded, on a board where an isolated generator takes
+ // two reactors and makes four times the power. That seed scored 32% of the
+ // Singularity bound on Event Horizon where the base seed scores 60% of its
+ // own (`npm run profile`), and the first repair had to double it. The fit
+ // is sized from the rated figure and the plain entry is what is written, so
+ // `simulateIsland` rates the tile from its neighbours as it always does.
+ // Off under every other rule, so the fixtures reproduce byte for byte.
+ //
+ // Measured with the survey (`--anomaly singularity_isolation --seed 1`):
+ // after the first repair the seed reads 78-88% of the bound on the eight
+ // largest islands against the plain one's 50-64%. At 15s x 5 runs that is
+ // a wash, -1.5% to +2.2% per map and every map inside its band; at 5s x 3
+ // runs, where the budget binds, Event Horizon gains 14% and Shadowspire
+ // 18%, four maps 2-4%, Ash Bay and Magma Rift read 4% and 1% lower inside
+ // their bands. With generator7 at tier 3 — the roster where the bonus is
+ // worth most — every map is inside 0.8%.
+ const isolation = ctx.isolation;
+ const isolatedSeed =
+ sizeByIsolation &&
+ isolation !== null &&
+ isolation.role === "generator" &&
+ topGenerator !== null &&
+ ctx.rateIsolated(topGenerator, true).effectiveValue <
+ ctx.rateIsolated(topGenerator, false).effectiveValue;
+ const fitGenerators = isolatedSeed
+ ? generators.map((g) => ctx.rateIsolated(g, false))
+ : generators;
+ const fitGenerator = fitGenerators.length > 0 ? fitGenerators[0] : null;
+ const plainOf = new Map();
+ for (let i = 0; i < generators.length; i++)
+ plainOf.set(fitGenerators[i], generators[i]);
+ /** Whether a generator written on `tile` would crowd, or be crowded by, one. */
+ const besideGenerator = (tile: number): boolean => {
+ const nb = ctx.neighbors[tile];
+ for (let k = 0; k < nb.length; k++) {
+ const b = placement[nb[k]];
+ if (b !== null && b.type === "generator") return true;
+ }
+ return false;
+ };
+
+ const canHub =
+ topReactor !== null && topGenerator !== null && topCooler !== null;
+ const canDp = directProducers.length > 0 && topCooler !== null;
+ const trySharedReactor =
+ canHub &&
+ Math.ceil(topReactor!.effectiveValue / fitGenerator!.effectiveValue) >= 2;
+
+ // Under a cooling pool a hub is sized with a phantom cooler that covers any
+ // waste from nowhere, so every neighbour slot goes to reactors and no cooler
+ // is written beside a hub. The cooling it really needs is charged as a
+ // board-wide debt instead: counted into each candidate's rank as the
+ // fractional tiles it will cost, reserved out of the free tiles so a hub can
+ // never crowd out the coolers that keep it online, and paid off after the
+ // hubs are down onto the least-connected free tiles — the scraps no hub can
+ // use, which under the pool are worth exactly what a well-connected tile is.
+ const fitCooler: EffectiveBuilding | null =
+ pooled && topCooler !== null
+ ? { ...topCooler, effectiveValue: POOL_COOLER_VALUE }
+ : topCooler;
+ const coolerValue =
+ topCooler !== null ? ctx.rateRole(topCooler).effectiveValue : 0;
+ /** Coolers the pool needs for `waste`, at the rated figure. */
+ const coolersFor = (waste: number): number =>
+ waste > EPS && coolerValue > 0 ? Math.ceil(waste / coolerValue) : 0;
+ /** What the board's producers are owed so far, from the simulation. */
+ let debt = 0;
+ const actualWaste = (): number => {
+ const rows = simulateIsland(placement, ctx, true).placements;
+ let sum = 0;
+ for (let i = 0; i < rows.length; i++) sum += rows[i].wasteHeatGenerated;
+ return sum;
+ };
+ /** Whether claiming `tiles` still leaves room for the coolers `waste` needs. */
+ const reserves = (tiles: number, waste: number): boolean =>
+ !pooled || numAvailable - tiles >= coolersFor(debt + waste);
+ /** Tiles a candidate charges for: its own, plus the pool's share under one. */
+ const cost = (tiles: number, waste: number): number =>
+ pooled ? tiles + waste / coolerValue : tiles;
+ /** The tiles each claim took, newest last, for the pooled backstop. */
+ const groups: number[][] = [];
+
+ const hubFitCache = new Map();
+ const dpFitCache = new Map();
+ const sharedScratch = emptyPlacement(ctx.n);
+
+ // A shared fit is simulated on an otherwise empty board, so it is a function
+ // of the neighbourhood's *shape* alone — which of the eight directions hold a
+ // free tile — as long as every tile rates a building the same: the tile
+ // order the distribution runs in is the spatial order, which the same shape
+ // reproduces wherever it sits. That is true under a uniform rating and under
+ // a role scale, which rates every tile alike; a terrain bonus is per tile and
+ // keeps the uncached path. Memoised by that shape, because this is the whole
+ // cost of the seed: it used to re-simulate every generator combination for
+ // every stale tile on every round, 0.5-0.8s on the largest shipped islands.
+ const sharedFitByShape =
+ ctx.uniformRating || ctx.anomaly.rule === "shared_cooling"
+ ? new Map()
+ : null;
+ const sharedFit = (tile: number, neighbors: number[]): SharedFit | null => {
+ if (sharedFitByShape === null) {
+ return bestSharedReactorFit(
+ tile,
+ neighbors,
+ reactors,
+ fitGenerator!,
+ fitCooler!,
+ ctx,
+ sharedScratch,
+ pooled,
+ );
+ }
+ const shape = neighbourhoodShape(ctx, tile, neighbors);
+ let relative = sharedFitByShape.get(shape.mask);
+ if (relative === undefined) {
+ const fit = bestSharedReactorFit(
+ tile,
+ neighbors,
+ reactors,
+ fitGenerator!,
+ fitCooler!,
+ ctx,
+ sharedScratch,
+ pooled,
+ );
+ relative = fit === null ? null : toRelativeFit(fit, shape.directionOf);
+ sharedFitByShape.set(shape.mask, relative);
+ }
+ return relative === null
+ ? null
+ : fromRelativeFit(relative, tile, shape.tileAt);
+ };
+
+ const hubFit = (slots: number): HubFit | null => {
+ let fit = hubFitCache.get(slots);
+ if (fit === undefined) {
+ fit = canHub
+ ? bestHubFit(slots, fitGenerators, reactors, fitCooler!)
+ : null;
+ hubFitCache.set(slots, fit);
+ }
+ return fit;
+ };
+
+ const dpFit = (slots: number): DpFit | null => {
+ let fit = dpFitCache.get(slots);
+ if (fit === undefined) {
+ fit = canDp ? bestDpFit(slots, directProducers, fitCooler!) : null;
+ dpFitCache.set(slots, fit);
+ }
+ return fit;
+ };
+
+ const candidateCache = new Array(ctx.n).fill(null);
+ const stale = new Uint8Array(ctx.n);
+ for (const tile of buildableOrder) stale[tile] = 1;
+
+ while (numAvailable > 0) {
+ if (performance.now() >= deadlineMs) break;
+
+ const staleList = buildableOrder.filter((tile) => stale[tile] === 1);
+ for (const tile of staleList) {
+ if (performance.now() >= deadlineMs) break;
+
+ const neighbors = availableNeighbors(tile, available, ctx);
+ let entry: CandidateEntry | null = null;
+ const consider = (candidate: CandidateEntry) => {
+ if (entry === null || candidate.powerPerTile > entry.powerPerTile)
+ entry = candidate;
+ };
+ // A hub's generator stands on `tile`; beside a placed generator it
+ // would be crowded, and crowd that one. Only the stale tiles are
+ // re-read here and a claim marks every tile beside it stale, so this
+ // sees each generator as it lands.
+ const crowds = isolatedSeed && besideGenerator(tile);
+
+ // Under a pool the fit shrinks until its reactors and its coolers both
+ // fit in what is left; under the base rules the first fit is the one.
+ let hub = crowds ? null : hubFit(neighbors.length);
+ while (hub !== null && !reserves(1 + hub.r, hub.waste)) {
+ hub = hub.r > 1 ? hubFit(hub.r - 1) : null;
+ }
+ if (hub !== null) {
+ const tilesUsed = 1 + hub.r + hub.c;
+ if (hub.power > EPS && tilesUsed > 0) {
+ consider({
+ powerPerTile: hub.power / cost(tilesUsed, hub.waste),
+ kind: "hub",
+ tile,
+ neighbors,
+ hub,
+ });
+ }
+ }
+
+ const dp = dpFit(neighbors.length);
+ if (dp !== null && reserves(1, dp.waste)) {
+ const tilesUsed = 1 + dp.c;
+ if (dp.power > EPS && tilesUsed > 0) {
+ consider({
+ powerPerTile: dp.power / cost(tilesUsed, dp.waste),
+ kind: "dp",
+ tile,
+ neighbors,
+ dp,
+ });
+ }
+ }
+
+ if (trySharedReactor && neighbors.length >= 3) {
+ const shared = sharedFit(tile, neighbors);
+ if (
+ shared !== null &&
+ !(isolatedSeed && shared.genPositions.some(besideGenerator))
+ ) {
+ const tilesUsed =
+ shared.reactorTiles.length +
+ shared.genPositions.length +
+ shared.coolerPositions.length;
+ if (tilesUsed > 0 && reserves(tilesUsed, shared.waste)) {
+ consider({
+ powerPerTile: shared.power / cost(tilesUsed, shared.waste),
+ kind: "shared",
+ tile,
+ neighbors,
+ shared,
+ });
+ }
+ }
+ }
+
+ candidateCache[tile] = entry;
+ }
+
+ stale.fill(0);
+
+ let best: CandidateEntry | null = null;
+ for (const tile of buildableOrder) {
+ if (!available[tile]) continue;
+ const entry = candidateCache[tile];
+ if (
+ entry !== null &&
+ (best === null || entry.powerPerTile > best.powerPerTile)
+ ) {
+ best = entry;
+ }
+ }
+ if (best === null) break;
+
+ if (pooled) {
+ // The debt has grown since a candidate untouched by the last claim was
+ // sized, so it may no longer leave room for its coolers. Re-size every
+ // open tile against the debt as it stands: a candidate sized against the
+ // current debt passes this at once, so the loop cannot spin.
+ const tiles =
+ best.kind === "hub"
+ ? 1 + best.hub!.r
+ : best.kind === "dp"
+ ? 1
+ : best.shared!.reactorTiles.length +
+ best.shared!.genPositions.length;
+ const waste =
+ best.kind === "hub"
+ ? best.hub!.waste
+ : best.kind === "dp"
+ ? best.dp!.waste
+ : best.shared!.waste;
+ if (!reserves(tiles, waste)) {
+ for (const tile of buildableOrder) if (available[tile]) stale[tile] = 1;
+ continue;
+ }
+ }
+
+ const claimed: number[] = [];
+ if (best.kind === "hub") {
+ const { generator, reactor, r, c } = best.hub!;
+ put(placement, ctx, best.tile, plainOf.get(generator) ?? generator);
+ claimed.push(best.tile);
+ for (let i = 0; i < r; i++) {
+ put(placement, ctx, best.neighbors[i], reactor);
+ claimed.push(best.neighbors[i]);
+ }
+ for (let i = r; i < r + c; i++) {
+ put(placement, ctx, best.neighbors[i], topCooler);
+ claimed.push(best.neighbors[i]);
+ }
+ } else if (best.kind === "shared") {
+ const shared = best.shared!;
+ for (let i = 0; i < shared.reactorTiles.length; i++) {
+ put(placement, ctx, shared.reactorTiles[i], shared.reactorTiers[i]);
+ claimed.push(shared.reactorTiles[i]);
+ }
+ for (const n of shared.genPositions) {
+ put(placement, ctx, n, topGenerator);
+ claimed.push(n);
+ }
+ for (const n of shared.coolerPositions) {
+ put(placement, ctx, n, topCooler);
+ claimed.push(n);
+ }
+ } else {
+ const { dp, c } = best.dp!;
+ put(placement, ctx, best.tile, dp);
+ claimed.push(best.tile);
+ for (let i = 0; i < c; i++) {
+ put(placement, ctx, best.neighbors[i], topCooler);
+ claimed.push(best.neighbors[i]);
+ }
+ }
+
+ for (const tile of claimed) {
+ if (available[tile]) {
+ available[tile] = 0;
+ numAvailable--;
+ }
+ candidateCache[tile] = null;
+ }
+ for (const tile of claimed) {
+ const nb = ctx.neighbors[tile];
+ for (let k = 0; k < nb.length; k++) {
+ if (available[nb[k]]) stale[nb[k]] = 1;
+ }
+ }
+ if (pooled) {
+ groups.push(claimed);
+ // The simulated figure rather than the sum of the fits' estimates: two
+ // hubs down beside each other share reactors, and a generator fed by a
+ // neighbour's surplus makes waste no fit counted.
+ debt = actualWaste();
+ }
+ }
+
+ if (pooled && topCooler !== null) {
+ // Pay the debt off, least-connected tile first: a one-tile scrap holds a
+ // cooler as well as any tile does, and nothing else can use it. Ascending
+ // index breaks ties so the fill is a function of the board alone.
+ const free = buildableOrder
+ .filter((tile) => available[tile] === 1)
+ .sort(
+ (a, b) => ctx.neighbors[a].length - ctx.neighbors[b].length || a - b,
+ );
+ let cooling = 0;
+ let next = 0;
+ const payDown = () => {
+ while (next < free.length && !wasteIsCovered(debt, cooling)) {
+ const tile = free[next++];
+ put(placement, ctx, tile, topCooler);
+ cooling += placement[tile]!.effectiveValue;
+ }
+ };
+ payDown();
+
+ // A short pool is an all-offline board, and `stabilize` would then delete
+ // every producer. So the board has to come up here: one more cooler while
+ // there is a tile for it, and failing that the newest hub is taken back
+ // and its tiles spent on the pool instead.
+ while (debt > EPS && simulateIsland(placement, ctx).totalPower <= EPS) {
+ if (next < free.length) {
+ const tile = free[next++];
+ put(placement, ctx, tile, topCooler);
+ cooling += placement[tile]!.effectiveValue;
+ continue;
+ }
+ const group = groups.pop();
+ if (group === undefined) break;
+ for (const tile of group) placement[tile] = null;
+ for (const tile of group) free.push(tile);
+ debt = actualWaste();
+ payDown();
+ }
+ }
+
+ return placement;
+}
+
+/**
+ * `sizeByIsolation` is `constructSeed`'s: false builds the plain-sized seed
+ * under a role isolation, which `solveIsland` compares against the sized one.
+ * It means nothing under any other rule.
+ */
+export function constructMultiStartSeed(
+ ctx: IslandContext,
+ reactors: EffectiveBuilding[],
+ generators: EffectiveBuilding[],
+ coolers: EffectiveBuilding[],
+ directProducers: EffectiveBuilding[],
+ deadlineMs: number,
+ sizeByIsolation = true,
+): Placement {
+ const buildable = Array.from(ctx.tiles);
+
+ const passes = (
+ pooled: boolean,
+ sizeByIsolation = true,
+ ): { seed: Placement; power: number } => {
+ let bestSeed = constructSeed(
+ buildable,
+ ctx,
+ reactors,
+ generators,
+ coolers,
+ directProducers,
+ deadlineMs,
+ pooled,
+ sizeByIsolation,
+ );
+ let bestPower = simulateIsland(bestSeed, ctx).totalPower;
+
+ if (bestPower <= EPS) {
+ // The deadline elapsed mid-construction and left a seed that powers
+ // nothing. One full greedy pass costs about 25ms on the largest island
+ // in the game (`npm run profile`), so finish it: refining a degenerate
+ // seed wastes the whole budget and can return an empty layout for a
+ // solvable island.
+ bestSeed = constructSeed(
+ buildable,
+ ctx,
+ reactors,
+ generators,
+ coolers,
+ directProducers,
+ performance.now() + 1000,
+ pooled,
+ sizeByIsolation,
+ );
+ bestPower = simulateIsland(bestSeed, ctx).totalPower;
+ }
+
+ if (performance.now() >= deadlineMs - 50 || buildable.length < 6)
+ return { seed: bestSeed, power: bestPower };
+
+ const reversed = [...buildable].reverse();
+ const seedRev = constructSeed(
+ reversed,
+ ctx,
+ reactors,
+ generators,
+ coolers,
+ directProducers,
+ deadlineMs,
+ pooled,
+ sizeByIsolation,
+ );
+ const powerRev = simulateIsland(seedRev, ctx).totalPower;
+ if (powerRev > bestPower + EPS) {
+ bestPower = powerRev;
+ bestSeed = seedRev;
+ }
+
+ if (performance.now() < deadlineMs - 50) {
+ const rng = new Rng(42);
+ const shuffled = [...buildable];
+ rng.shuffle(shuffled);
+ const seedRand = constructSeed(
+ shuffled,
+ ctx,
+ reactors,
+ generators,
+ coolers,
+ directProducers,
+ deadlineMs,
+ pooled,
+ sizeByIsolation,
+ );
+ const powerRand = simulateIsland(seedRand, ctx).totalPower;
+ if (powerRand > bestPower + EPS) {
+ bestPower = powerRand;
+ bestSeed = seedRand;
+ }
+ }
+
+ return { seed: bestSeed, power: bestPower };
+ };
+
+ const local = passes(false, sizeByIsolation);
+ if (ctx.anomaly.rule !== "shared_cooling") return local.seed;
+
+ // Under a pool the same three orders are run engine-first as well, and the
+ // strongest seed of the six starts the search. The local passes stay in:
+ // they are what the search started from before the pooled ones existed, and
+ // a pooled seed that comes back weaker is not an improvement.
+ const pooled = passes(true);
+ return pooled.power > local.power + EPS ? pooled.seed : local.seed;
+}
diff --git a/packages/solver/src/solver/search/stability.ts b/packages/solver/src/solver/search/stability.ts
new file mode 100644
index 0000000..7a0dd32
--- /dev/null
+++ b/packages/solver/src/solver/search/stability.ts
@@ -0,0 +1,161 @@
+import { EPS } from "../constants";
+import type { IslandContext } from "../context";
+import { type SimPlacedBuilding, simulateIsland } from "../simulate";
+import type { EffectiveBuilding, Placement } from "../types";
+import { isDirectProducer } from "./placement";
+
+/**
+ * What makes a layout an answer: every producer online, at the target and at
+ * every step of an upgrade plan. See the stability rule in `placementSearch.ts`.
+ */
+
+/**
+ * Tiles holding a generator or direct producer that generates no power —
+ * either because it is overheating (waste above the cooling routed to it) or
+ * because it never received any heat at all.
+ *
+ * A layout containing one of these is not a valid answer: the goal is a fully
+ * stable grid where every producer runs, so these tiles are wasted at best and
+ * represent an overheating building at worst.
+ */
+export function offlineProducers(
+ placement: Placement,
+ rows: SimPlacedBuilding[],
+): number[] {
+ const offline: number[] = [];
+ for (let i = 0; i < rows.length; i++) {
+ const p = rows[i];
+ const building = placement[p.idx];
+ if (building === null) continue;
+ const producer =
+ building.type === "generator" || isDirectProducer(building);
+ if (producer && p.powerGenerated <= EPS) offline.push(p.idx);
+ }
+ return offline;
+}
+
+/**
+ * A supplier sending out less than the tile it stands on was rated to carry —
+ * the walk's cue that a generator beside it would have somewhere to put the
+ * rest.
+ *
+ * Against `ratedValue`, never `baseValue`: `heatProduced` is what the
+ * distribution actually sent, capped by the tile's RATING, so measuring it
+ * against the authored tier compares a scaled delivery with an unscaled ceiling
+ * and only calls a reactor under-fed below 1/k fill for a tile rated k — on a
+ * Tidal shore k is its `multiplier`, under a maxed Stellar Forge the
+ * research's. The move then stops firing on much of what it exists for, with
+ * no number anywhere disagreeing. It is a named function so that the reading
+ * can be pinned by a test rather than only stated here.
+ */
+export function isUnderFed(row: SimPlacedBuilding): boolean {
+ return row.heatProduced > EPS && row.heatProduced < row.ratedValue - EPS;
+}
+
+/** Allocation-free `offlineProducers(...).length > 0`, for the search's inner loops. */
+export function anyOfflineProducer(
+ placement: Placement,
+ rows: SimPlacedBuilding[],
+): boolean {
+ for (let i = 0; i < rows.length; i++) {
+ const p = rows[i];
+ const building = placement[p.idx];
+ if (building === null) continue;
+ const producer =
+ building.type === "generator" || isDirectProducer(building);
+ if (producer && p.powerGenerated <= EPS) return true;
+ }
+ return false;
+}
+
+/**
+ * An upgrade plan: the rosters a layout has to stay stable under besides the
+ * one it is scored at — the tiers the player holds today and every step on the
+ * way to the target. Each step is keyed by building id, because a placement
+ * holds the target roster's objects and the step's own figures are found by
+ * id; a building the step does not have stands on its tile as nothing.
+ *
+ * A step is simulated with the rating its tiles give it (`ctx.rate`), so a
+ * terrain bonus or a cooling pool applies at every step exactly as it does at
+ * the target.
+ */
+interface UpgradePlan {
+ steps: Map[];
+ /** Scratch the step layout is written into, one per island. */
+ buffer: Placement;
+}
+
+/**
+ * Held beside the context rather than on it, so the context type — and every
+ * stage that takes one — is untouched when there is no plan, which is always
+ * the case for the fixtures.
+ */
+export const upgradePlans = new WeakMap();
+
+/**
+ * The producers that go offline under the first plan step the layout fails, or
+ * null when it runs at every step. Free when the island has no plan.
+ */
+function planFailure(
+ placement: Placement,
+ ctx: IslandContext,
+): number[] | null {
+ const plan = upgradePlans.get(ctx);
+ if (plan === undefined) return null;
+ const buffer = plan.buffer;
+ for (const step of plan.steps) {
+ for (let t = 0; t < ctx.n; t++) {
+ const b = placement[t];
+ const at = b === null ? undefined : step.get(b.id);
+ buffer[t] = at === undefined ? null : ctx.rate(t, at);
+ }
+ const sim = simulateIsland(buffer, ctx);
+ const offline = offlineProducers(buffer, sim.placements);
+ if (offline.length > 0) return offline;
+ }
+ return null;
+}
+
+/** Whether the layout runs at every step of the island's plan. */
+export function planHolds(placement: Placement, ctx: IslandContext): boolean {
+ return planFailure(placement, ctx) === null;
+}
+
+interface StableLayout {
+ placement: Placement;
+ power: number;
+ rows: SimPlacedBuilding[];
+}
+
+/**
+ * Drops producers that generate no power until every remaining one is online.
+ *
+ * This has to iterate rather than delete them all in one pass: removing an
+ * offline generator hands the heat it was absorbing to its neighbours, which
+ * can push THEIR waste past what their coolers cover and take them offline in
+ * turn. Power can legitimately fall as a result — a layout that only scores
+ * well by parking heat in an overheating building is not a layout we want.
+ */
+export function stabilize(
+ placement: Placement,
+ ctx: IslandContext,
+): StableLayout {
+ const current = placement.slice();
+ let sim = simulateIsland(current, ctx);
+
+ for (;;) {
+ let offline = offlineProducers(current, sim.placements);
+ // Stable at the target is not enough under a plan: a producer that
+ // overheats at any step goes too, and so on until every step runs.
+ if (offline.length === 0) offline = planFailure(current, ctx) ?? [];
+ if (offline.length === 0) {
+ return {
+ placement: current,
+ power: sim.totalPower,
+ rows: sim.placements,
+ };
+ }
+ for (const tile of offline) current[tile] = null;
+ sim = simulateIsland(current, ctx);
+ }
+}
diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts
index 06d6c88..99dfa4b 100755
--- a/packages/solver/src/solver/simulate.ts
+++ b/packages/solver/src/solver/simulate.ts
@@ -82,10 +82,16 @@ export function simulateIsland(
let numCoolers = 0;
let numOccupied = 0;
+ // A role-isolation rule re-rates a layout below; the copy it starts from is
+ // taken in this same pass rather than in one of its own.
+ const isolation = ctx.isolation;
+ const rated = ctx.ratedLayout;
+
// Ascending tile index is the game's spatial processing order, so every list
// built here is already sorted the way the distribution rules require.
for (let t = 0; t < n; t++) {
const b = placement[t];
+ if (isolation !== null) rated[t] = b;
if (b === null) continue;
numOccupied++;
if (b.type === "generator") {
@@ -122,11 +128,7 @@ export function simulateIsland(
// rather than the buffer it is filling: the test is on what a neighbour *is*,
// which no rating changes, so there is no order to get right.
let layout = placement;
- const isolation = ctx.isolation;
if (isolation !== null) {
- const rated = ctx.ratedLayout;
- for (let t = 0; t < n; t++) rated[t] = placement[t];
-
const affected =
isolation.role === "generator"
? generatorTiles
diff --git a/packages/solver/src/solver/solver.ts b/packages/solver/src/solver/solver.ts
index 6921dea..50055d4 100755
--- a/packages/solver/src/solver/solver.ts
+++ b/packages/solver/src/solver/solver.ts
@@ -109,6 +109,35 @@ export function islandBudgetsS(
return budgets;
}
+/**
+ * One attempt's per-island budgets, stretched to the attempt's wall-clock when
+ * the whole run fits the pool at once.
+ *
+ * `islandBudgetsS` divides an attempt's budget so the slices **sum** to it,
+ * which is what keeps a run that queues — more tasks than workers — to the
+ * cost it advertises. But islands run concurrently, so when every task of the
+ * run has a worker of its own the sum buys nothing: the run lasts as long as
+ * its largest slice, and on a board of two large islands that was 16-24s of a
+ * "30s" Quick run. There the slices are scaled up together until the largest
+ * is the whole budget — the run takes exactly what the mode says, and every
+ * island gets more time than before, in the same proportions.
+ *
+ * Shared by the app's coordinator, its run estimate and the CLI's pooled run,
+ * so none of them can disagree.
+ */
+export function fitBudgetsToPool(
+ budgetsS: readonly number[],
+ attemptBudgetS: number,
+ attempts: number,
+ poolSize: number,
+): number[] {
+ const largest = Math.max(0, ...budgetsS);
+ if (largest <= 0 || attempts * budgetsS.length > poolSize)
+ return [...budgetsS];
+ const scale = attemptBudgetS / largest;
+ return budgetsS.map((s) => s * scale);
+}
+
export interface IslandPlan {
islands: IslandSubGrid[];
effectiveBuildings: EffectiveBuilding[];
diff --git a/packages/solver/tests/goldenLayoutsFullTier.test.ts b/packages/solver/tests/goldenLayoutsFullTier.test.ts
index ceb247a..fa264d3 100644
--- a/packages/solver/tests/goldenLayoutsFullTier.test.ts
+++ b/packages/solver/tests/goldenLayoutsFullTier.test.ts
@@ -216,10 +216,8 @@ describe("a three-tile row", () => {
* the ecto's 2.88e23 and the rest goes nowhere; the cooler is idling at 7%.
* Power is the generator's full rated 1.06e23.
*/
- golden(
- ["XGC"],
- { ecto_compressor: 1, generator7: 1, cooler8: 1 },
- () => sharedOutput(1, 1),
+ golden(["XGC"], { ecto_compressor: 1, generator7: 1, cooler8: 1 }, () =>
+ sharedOutput(1, 1),
);
});
@@ -231,10 +229,8 @@ describe("a four-tile square", () => {
* The fourth tile goes to a second generator, not a second reactor: the
* first ecto was already spilling more than a whole generator's worth.
*/
- golden(
- ["GC", "XG"],
- { ecto_compressor: 1, generator7: 2, cooler8: 1 },
- () => sharedOutput(1, 2),
+ golden(["GC", "XG"], { ecto_compressor: 1, generator7: 2, cooler8: 1 }, () =>
+ sharedOutput(1, 2),
);
it("doubles the three-tile row exactly", () => {
@@ -253,10 +249,8 @@ describe("a five-tile island", () => {
* converted, is 2.1499e23 against 2.12e23.
*/
const LAYOUT = ["GCG", "GX."];
- golden(
- LAYOUT,
- { ecto_compressor: 1, generator7: 3, cooler8: 1 },
- () => sharedOutput(1, 3),
+ golden(LAYOUT, { ecto_compressor: 1, generator7: 3, cooler8: 1 }, () =>
+ sharedOutput(1, 3),
);
it("beats the four-tile square by only the ecto's overflow", () => {
@@ -311,10 +305,8 @@ describe("an eight-tile island", () => {
* last 8e21 — 4.2997e23, beating 3 ectos + 4 generators' 4.24e23.
*/
const LAYOUT = ["GXG", "XCG", "GG."];
- golden(
- LAYOUT,
- { ecto_compressor: 2, generator7: 5, cooler8: 1 },
- () => sharedOutput(2, 5),
+ golden(LAYOUT, { ecto_compressor: 2, generator7: 5, cooler8: 1 }, () =>
+ sharedOutput(2, 5),
);
it("prefers a fifth generator to a third reactor", () => {
diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts
index 1310118..611f637 100644
--- a/packages/solver/tests/island.test.ts
+++ b/packages/solver/tests/island.test.ts
@@ -1175,6 +1175,59 @@ describe("the theoretical max-power bound", () => {
expect(totalPower).toBeGreaterThan(0);
});
+ it("is not beaten where the neighbour cap binds", () => {
+ /*
+ * A generator far stronger than its neighbours can feed, so each one is
+ * held to what its own ring can supply — and the bound now counts that
+ * ring by class, crediting an inland generator only the shore reactors
+ * and coolers it actually touches. The layouts here put generators on
+ * every kind of tile, beside shore neighbours and away from them; any
+ * that beat the bound would mean a ring had been under-counted.
+ */
+ const roster = basicRoster({ generatorValue: 1000 });
+ const island = islandFor(
+ ["GGGGGG", "GGGGGG", "GGGGGG", "GGGGGG", "GGGGGG"],
+ roster,
+ );
+ const bound = estimateTotalMaxPower([island], roster, tidal);
+ const ctx = buildIslandContext(
+ island.grid,
+ island.buildable,
+ tidal,
+ island.waterAdjacent,
+ );
+ const byLetter = { R: roster[0], G: roster[1], C: roster[2] };
+ const score = (rows: string[]) => {
+ const shape = placeOn(ctx, rows, byLetter);
+ return simulateIsland(
+ shape.map((b, t) => (b === null ? null : ctx.rate(t, b))),
+ ctx,
+ ).totalPower;
+ };
+
+ const handBuilt = [
+ ["RCRCRC", "CGRGRG", "RCRCRC", "CGRGRG", "RCRCRC"],
+ ["RRCRRC", "RGCRGC", "RRCRRC", "RGCRGC", "RRCRRC"],
+ ];
+ const powers = handBuilt.map(score);
+ for (const power of powers)
+ expect(power).toBeLessThanOrEqual(bound + EPS);
+ // Not vacuous: both run, the better at about three-quarters of the bound.
+ for (const power of powers) expect(power).toBeGreaterThan(0);
+
+ // And a spread of arbitrary ones, from a fixed stream.
+ let seed = 12345;
+ const next = () => (seed = (seed * 1103515245 + 12345) >>> 0) / 2 ** 32;
+ for (let n = 0; n < 300; n++) {
+ const rows = Array.from({ length: 5 }, () =>
+ Array.from({ length: 6 }, () => "RGC"[Math.floor(next() * 3)]).join(
+ "",
+ ),
+ );
+ expect(score(rows)).toBeLessThanOrEqual(bound + EPS);
+ }
+ });
+
it("comes in under the whole-island figure once an island has inland tiles", () => {
const roster = basicRoster();
// The bare 3x3's centre is the only inland tile, and it is enough.
diff --git a/packages/solver/tests/pipeline.test.ts b/packages/solver/tests/pipeline.test.ts
index ffa2447..3f5063e 100644
--- a/packages/solver/tests/pipeline.test.ts
+++ b/packages/solver/tests/pipeline.test.ts
@@ -30,7 +30,7 @@ import {
splitGridIntoIslands,
} from "../src/solver/island";
import { simulateIsland } from "../src/solver/simulate";
-import { islandBudgetsS, solve } from "../src/solver/solver";
+import { fitBudgetsToPool, islandBudgetsS, solve } from "../src/solver/solver";
import type { Placement, Tile } from "../src/solver/types";
import { basicCatalogue, basicRoster, expectClose } from "./helpers";
@@ -296,4 +296,18 @@ describe("splitting the budget between islands", () => {
expect(islandBudgetsS([0], 30)).toEqual([0]);
expect(islandBudgetsS([], 30)).toEqual([]);
});
+ /*
+ * Islands run side by side, so when every task has a worker the run lasts
+ * as long as its largest slice, and the slices are stretched until that
+ * one is the whole budget. A run that queues keeps the summed split.
+ */
+ it("stretches the slices to the budget when the run fits the pool", () => {
+ expect(fitBudgetsToPool([27, 3], 30, 1, 2)).toEqual([30, 30 / 9]);
+ expect(fitBudgetsToPool([27, 3], 30, 2, 4)).toEqual([30, 30 / 9]);
+ });
+
+ it("keeps the summed split once the run has to queue", () => {
+ expect(fitBudgetsToPool([27, 3], 30, 2, 3)).toEqual([27, 3]);
+ expect(fitBudgetsToPool([0], 30, 1, 8)).toEqual([0]);
+ });
});
diff --git a/packages/solver/tests/workerSafety.test.ts b/packages/solver/tests/workerSafety.test.ts
index 5f54a44..d959dbc 100755
--- a/packages/solver/tests/workerSafety.test.ts
+++ b/packages/solver/tests/workerSafety.test.ts
@@ -76,15 +76,19 @@ export function violationFor(specifier: string, from: string): string | null {
if (!from.startsWith("solver/")) {
return null;
}
- if (specifier.startsWith("./")) {
- return null; // within src/solver/
- }
- const sibling = /^\.\.\/([^/]+)/.exec(specifier)?.[1];
- if (sibling === undefined) {
+ // Resolved against the importing file's own folder, so a module in a
+ // subfolder of src/solver/ reaching `../constants` is still within the
+ // engine, and one reaching `../../encoding/x` is caught like any other.
+ const target = posix.normalize(posix.join(posix.dirname(from), specifier));
+ if (target.startsWith("../") || target === "..") {
return "escapes src/ entirely";
}
- if (!ALLOWED_SIBLING_FOLDERS.includes(sibling)) {
- return `reaches into src/${sibling}/, which the engine may not depend on`;
+ const folder = target.split("/")[0];
+ if (folder === "solver") {
+ return null; // within src/solver/
+ }
+ if (!ALLOWED_SIBLING_FOLDERS.includes(folder)) {
+ return `reaches into src/${folder}/, which the engine may not depend on`;
}
return null;
}
@@ -133,6 +137,14 @@ describe("worker safety", () => {
);
expect(violationFor("../utils/formatters", from)).toMatch(/src\/utils/);
expect(violationFor("pixi.js", from)).toMatch(/bare package/);
+ // A subfolder of the engine is held to the same rule, resolved from where
+ // it stands rather than read off the specifier's first segment.
+ expect(
+ violationFor("../../encoding/blueprint", "solver/search/seed.ts"),
+ ).toMatch(/src\/encoding/);
+ expect(violationFor("../../../x", "solver/search/seed.ts")).toMatch(
+ /escapes src/,
+ );
// A bare specifier is refused wherever it is written, not only in solver/.
expect(violationFor("pako", "encoding/blueprint.ts")).toMatch(
/bare package/,
@@ -142,6 +154,11 @@ describe("worker safety", () => {
it("allows what each layer legitimately needs", () => {
expect(violationFor("./types", "solver/placementSearch.ts")).toBeNull();
expect(violationFor("./constants", "solver/island.ts")).toBeNull();
+ expect(violationFor("../constants", "solver/search/seed.ts")).toBeNull();
+ expect(violationFor("./placement", "solver/search/seed.ts")).toBeNull();
+ expect(
+ violationFor("../../data/anomalies", "solver/search/seed.ts"),
+ ).toBeNull();
expect(
violationFor("../data/effectiveBuildings", "solver/solver.ts"),
).toBeNull();
diff --git a/src/lib/components/modals/SolveModal.svelte b/src/lib/components/modals/SolveModal.svelte
index 70a25ac..b46f75e 100755
--- a/src/lib/components/modals/SolveModal.svelte
+++ b/src/lib/components/modals/SolveModal.svelte
@@ -60,8 +60,8 @@
Discard this layout
- Start fresh — whatever the run finds stands, higher or lower, and this
- island's other layouts go with it.
+ Deleted now, with this island's other layouts — whatever the run finds
+ stands, higher or lower.
diff --git a/src/lib/components/modals/SolveModeInfoModal.svelte b/src/lib/components/modals/SolveModeInfoModal.svelte
index 9de872e..6b3d81c 100755
--- a/src/lib/components/modals/SolveModeInfoModal.svelte
+++ b/src/lib/components/modals/SolveModeInfoModal.svelte
@@ -1,6 +1,7 @@
@@ -50,8 +51,8 @@
The optimizer does not compute the best layout — it walks toward one at
random and cools as it goes. Past the first few seconds a walk mostly
polishes the arrangement it already has, so a longer run is worth much
- less than more runs. Each option below is the same search repeated
- a different number of times; the best result per island is kept.
+ less than more runs. Quick is one search; Deep and Max repeat a shorter
+ one a different number of times, and the best result per island is kept.
@@ -85,7 +86,7 @@
The second figure is what it should actually take here. Searches run in
parallel — this browser gives {poolSize}
{poolSize === 1 ? "worker" : "workers"}, shared across the map's islands —
- so fifty searches cost nothing like fifty times one.
+ so twenty-five searches cost nothing like twenty-five times one.
@@ -98,8 +99,7 @@
{#if budgetScale !== 1}
Under {configState.activeAnomaly.name} every search runs {budgetScale}×
- as long. A generator's output depends on its neighbours there, so a walk
- needs the extra time to find layouts that keep them apart.
+ as long. {attemptBudgetReason(configState.anomalyId)}
{/if}
diff --git a/src/lib/components/sidebar/SolveModeSelector.svelte b/src/lib/components/sidebar/SolveModeSelector.svelte
index c61222e..3cb70ad 100755
--- a/src/lib/components/sidebar/SolveModeSelector.svelte
+++ b/src/lib/components/sidebar/SolveModeSelector.svelte
@@ -6,14 +6,14 @@
* How the next run spends its time, offered as a choice rather than picked
* for the user.
*
- * The search is stochastic, so one 30s walk is a single sample and ten 10s
- * walks are ten — usually the better answer, and reliably the better
+ * The search is stochastic, so one 30s walk is a single sample and five 20s
+ * walks are five — usually the better answer, and reliably the better
* *shortlist*, since ties found by different attempts are pooled. What it
* costs is wall-clock, and how much depends on the machine: the attempts go
* through the same worker pool the islands already use, so a browser
* reporting eight cores overlaps most of them and a single-core one pays the
* full serial price. That is the whole reason the estimate is printed here —
- * "10 × 10s" is not a duration on its own.
+ * "5 × 20s" is not a duration on its own.
*
* It sits directly above the Run button rather than inside the re-run dialog
* because it has to be answerable on a first run too, when no dialog opens.
diff --git a/src/lib/state/CLAUDE.md b/src/lib/state/CLAUDE.md
index 97b9b5b..0b4b02f 100644
--- a/src/lib/state/CLAUDE.md
+++ b/src/lib/state/CLAUDE.md
@@ -151,7 +151,7 @@ watchdog and the run all read the scaled mode, so none of them can disagree.
Attempts are ordinary pool tasks: `attempts × islands` go into the same queue
that runs one island per worker, so extra searches cost wall-clock only once the
pool is full. That is why `estimatedRunMs` exists and why the control prints it —
-"10 × 10s" is not a duration until you know how many workers the browser gives
+"5 × 20s" is not a duration until you know how many workers the browser gives
you. `estimateMakespanMs` replays the coordinator's own greedy dispatch over the
task durations rather than guessing.
@@ -169,12 +169,27 @@ pooled rather than discarded. Finished attempts only: a progress report is a
snapshot of a search still moving, so the coordinator streams those to the screen
on a separate, monotonic track and never files them as answers.
+**The page gets at most one progress update per report interval, however many
+workers are reporting.** Each worker reports on its own clock, so a pool of eight
+handed the main thread up to eight full boards a second to rebuild, re-score and
+redraw. The workers' rate does not slow with the machine and the main thread's
+cost does: on a page slowed 6x that kept it 85% busy, and a main thread that
+falls behind cannot read the next message either — the board stops updating, Stop
+is never forwarded, and a queued task waits on a DONE nobody has read. Every
+report still lands in the monotonic track; only the page's copy is coalesced, and
+a pending update is cancelled when the run settles so it cannot draw over the
+finished result.
+
**A re-run has to earn its place.** `runOptimizer({ keepBest })` captures the
shortlist on screen and puts the new result back only if it scores higher; a
defended layout keeps its _own_ run's duration and timestamp, because it is still
that solve and the card prints both. The search is stochastic and the budget
short, so a second run genuinely can come back worse — which is why the choice is
-put to the user (`uiState.requestSolve`) rather than assumed.
+put to the user (`uiState.requestSolve`) rather than assumed. The other answer,
+discarding, deletes the layout from the screen and from storage as the run
+starts (`uiState.startSolve`), not when its result lands: a layout the player
+threw away should not stay on the board through the run, and a run stopped before
+it reports would otherwise hand it back.
Two things about "the layout on screen" are load-bearing:
@@ -204,8 +219,16 @@ The list is filled from two directions: one run usually fills it on the shipped
islands, and `chooseSolveVariants` pools further runs that **tie** into it (a
fresh layout joins only if it clears `MIN_ALTERNATE_DISTANCE`; held layouts go in
unfiltered and stay in front, so the user's pick keeps its index). A run that
-beats it replaces the list; one that comes back lower is defended against. That
-policy is exported and pinned by `state/solveVariants.test.ts` rather than buried
+beats it replaces the list; one that comes back lower is defended against.
+
+Before that whole-board comparison, `composeIslandBests` takes each **island's**
+best across the held and fresh lists. Islands never interact, so when each run
+won some islands, a board made of every island's winner beats both. That board
+stands in for the fresh run. Without the merge, a re-run that found a better
+large island would win on the total and bring a worse small island with it. It
+does nothing under a shared cooling pool (one island) and returns null when one
+board already holds every best, leaving the plain rules above. Both policies are
+exported and pinned by `state/solveVariants.test.ts` rather than buried
in `runOptimizer`, because it is the whole meaning of "ten layouts at the same
power".
@@ -221,10 +244,15 @@ one, which is what it was.
`MAX_STORED_SOLVES`) and re-hung by `restore()`, which `hydrateState()` awaits
_after_ `layoutState.hydrate()` — deciding whether a stored result is still valid
means reading the board it was solved against. Validity is a stored signature:
-the terrain (`blueprintKey(grid)`, no placements) and the roster. Hand-placed
-buildings are deliberately outside it because the solver ignores them, so
-building by hand does not invalidate a solve; clearing an obstacle or changing an
-unlock does.
+the terrain (`blueprintKey(grid)`, no placements), the roster, the anomaly and
+the research. Hand-placed buildings are deliberately outside it because the
+solver ignores them, so building by hand does not invalidate a solve; clearing an
+obstacle does. A mismatch on anything **but** the terrain is not a different
+board, so `restore()` re-rates rather than discards: the same `#refile` a game
+import uses re-tiers the shapes to the current unlocks, drops any layout using a
+building no longer unlocked (the solve too, once none are left), re-scores and
+re-files under the new signature. That is what lets a Setup change made with one
+island on screen leave every other island's solve standing.
A run can take five minutes, which is what makes losing one expensive.
**Switching islands is a change of view, not a deletion**: `TemplateSelector`
@@ -482,6 +510,16 @@ player owns nothing there yet. Because it
replaces work, the Import dialog summarises the code first and takes two presses.
The template-to-game mapping is `IslandTemplate.game` in `data/maps.ts`.
+**An import keeps the solves it leaves buildable.** It changes the roster, the
+research and usually the terrain, all of which are in `solveSignature()`, so
+left alone `restore()` would discard every island's solve on its next open.
+`solverState.carryOverSolves` runs first and decides per island: a solve goes
+for an island still locked in the game or one searched under another anomaly;
+a layout goes if the import put an obstacle under one of its buildings or no
+longer unlocks one of them (the solve with it once none survive); the rest are
+re-tiered, re-scored on the island's new board and re-filed under its new
+signature, without searching again.
+
### The transformer is special
Two rules the other tile types do not have, both enforced in `editorState`:
diff --git a/src/lib/state/layout.svelte.ts b/src/lib/state/layout.svelte.ts
index 312fc62..93b3fdb 100755
--- a/src/lib/state/layout.svelte.ts
+++ b/src/lib/state/layout.svelte.ts
@@ -538,6 +538,21 @@ class LayoutState {
this.recalculate();
}
+ /**
+ * The terrain an island would load with — its saved layout's, or its map's —
+ * without putting it on screen. Null for an id that names no island, or a
+ * board that cannot be read.
+ */
+ async boardOf(templateId: string): Promise {
+ const template = this.findTemplate(templateId);
+ if (!template) return null;
+ const saved = await this.#decodeSaved(
+ templateId,
+ this.savedGrids[templateId],
+ );
+ return (saved ?? (await this.#decodeTemplate(template)))?.grid ?? null;
+ }
+
/**
* Switches templates: pristine terrain, then the user's saved layout for it
* — terrain *and* placements — if they have one, landing as one write.
diff --git a/src/lib/state/solveCarryOver.test.ts b/src/lib/state/solveCarryOver.test.ts
new file mode 100644
index 0000000..7960fe9
--- /dev/null
+++ b/src/lib/state/solveCarryOver.test.ts
@@ -0,0 +1,321 @@
+/**
+ * What importing the game does to the solves already stored for each island.
+ *
+ * An import changes the roster, the research and usually the terrain, and all
+ * three are in a solve's signature — so without `carryOverSolves` every
+ * island's solve would be discarded the next time it was opened. These pin
+ * which ones survive: a solve is dropped only for a locked island, another
+ * anomaly, or layouts the import made unbuildable.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ blueprintKey,
+ decodeBlueprint,
+ encodeBlueprint,
+ ISLAND_TEMPLATES,
+} from "@reactor2/solver";
+import type { AnomalyId } from "@reactor2/solver";
+import type { OptimizationResult, PlacedBuilding, Tile } from "../types";
+import type { StoredPlacement } from "../storage/storage";
+
+// The storage helpers are SSR-guarded on `typeof window`, so a test running in
+// node needs both a window and a localStorage before it imports anything that
+// touches them.
+const store = new Map();
+(globalThis as { window?: unknown }).window = globalThis;
+(globalThis as { localStorage?: unknown }).localStorage = {
+ getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
+ setItem: (key: string, value: string) => void store.set(key, String(value)),
+ removeItem: (key: string) => void store.delete(key),
+ clear: () => store.clear(),
+};
+
+const { configState } = await import("./config.svelte");
+const { layoutState } = await import("./layout.svelte");
+const { solverState } = await import("./solver.svelte");
+const { solverStorage } = await import("../storage/storage");
+const { createPlacementAtLevel } = await import("../data/placements");
+const { SolverWorkerClient } = await import("../worker/workerClient");
+
+const ISLAND = "island1";
+const template = ISLAND_TEMPLATES.find((t) => t.id === ISLAND)!;
+const { grid: mapGrid } = await decodeBlueprint(template.code);
+const grass = mapGrid.flat().filter((t) => t.type === "grass");
+
+/** A one-building layout on the `i`th grass tile of the island's map. */
+function layout(i: number, buildingId = "cooler1"): PlacedBuilding[] {
+ return [createPlacementAtLevel(buildingId, grass[i].x, grass[i].y, 0)];
+}
+
+function result(placements: PlacedBuilding[]): OptimizationResult {
+ return {
+ totalPower: 0,
+ placements,
+ activeTilesCount: placements.length,
+ unusedTilesCount: 0,
+ summary: {
+ totalHeatProduced: 0,
+ totalHeatConsumed: 0,
+ totalWasteGenerated: 0,
+ totalCoolingCapacity: 0,
+ },
+ theoreticalMaxPower: 0,
+ };
+}
+
+/** Stores a solve for the island, as a finished run would have. */
+function stage(
+ variants: PlacedBuilding[][],
+ selected = 0,
+ anomalyId: AnomalyId = "none",
+ signature = "the roster before the import",
+) {
+ solverStorage.saveSolve({
+ templateId: ISLAND,
+ signature,
+ result: result(variants[selected]),
+ variants: variants.map((v) =>
+ v.map((p): StoredPlacement => [p.buildingId, p.x, p.y, p.baseValue]),
+ ),
+ selectedVariant: selected,
+ anomalyId,
+ planKey: "",
+ durationMs: 30_000,
+ finishedAt: 1,
+ });
+}
+
+/** The island's map with tile `i` turned to rock, saved as its layout. */
+async function rockOn(i: number) {
+ const grid: Tile[][] = mapGrid.map((row) =>
+ row.map((t) =>
+ t.x === grass[i].x && t.y === grass[i].y ? { ...t, type: "rock" } : t,
+ ),
+ );
+ layoutState.savedGrids[ISLAND] = { code: await encodeBlueprint(grid, []) };
+}
+
+const shapesOf = (id: string) =>
+ solverStorage.loadSolve(id)?.variants?.map((v) => [v[0][1], v[0][2]]);
+
+describe("carrying stored solves through a game import", () => {
+ beforeEach(async () => {
+ store.clear();
+ layoutState.savedGrids = {};
+ layoutState.customIslands = [];
+ layoutState.activeTemplateId = ISLAND;
+ await layoutState.hydrate({});
+ // The roster the import brings: a different one from the solve's.
+ configState.buildingUpgrades = { cooler1: 5, generator: 0 };
+ configState.prestigeLevels = {};
+ configState.setAnomaly("none");
+ });
+
+ it("keeps a solve the import left buildable, re-filed for the new roster", async () => {
+ stage([layout(0)]);
+ await solverState.carryOverSolves([]);
+
+ const saved = solverStorage.loadSolve(ISLAND)!;
+ expect(saved.signature).toBe(solverState.solveSignature(mapGrid));
+ // Re-tiered to what the import unlocks.
+ expect(saved.result.placements[0].baseValue).toBe(
+ createPlacementAtLevel("cooler1", 0, 0, 5).baseValue,
+ );
+ // And it is what the island now restores.
+ solverState.restore();
+ expect(solverState.variants).toHaveLength(1);
+ });
+
+ it("drops a solve for an island still locked in the game", async () => {
+ stage([layout(0)]);
+ await solverState.carryOverSolves([ISLAND]);
+ expect(solverStorage.loadSolve(ISLAND)).toBeNull();
+ });
+
+ it("drops a solve searched under another anomaly", async () => {
+ stage([layout(0)], 0, "cryo_nexus");
+ await solverState.carryOverSolves([]);
+ expect(solverStorage.loadSolve(ISLAND)).toBeNull();
+ });
+
+ it("drops only the layouts an imported obstacle now covers", async () => {
+ stage([layout(0), layout(1), layout(2)], 1);
+ await rockOn(0);
+ await solverState.carryOverSolves([]);
+
+ expect(shapesOf(ISLAND)).toEqual([
+ [grass[1].x, grass[1].y],
+ [grass[2].x, grass[2].y],
+ ]);
+ // The pick survives and keeps pointing at the same layout.
+ expect(solverStorage.loadSolve(ISLAND)?.selectedVariant).toBe(0);
+ });
+
+ it("drops the solve once an obstacle covers every layout", async () => {
+ stage([layout(0)]);
+ await rockOn(0);
+ await solverState.carryOverSolves([]);
+ expect(solverStorage.loadSolve(ISLAND)).toBeNull();
+ });
+
+ it("drops a layout that uses a building the import does not unlock", async () => {
+ stage([layout(0, "solar_panel"), layout(1)]);
+ await solverState.carryOverSolves([]);
+ expect(shapesOf(ISLAND)).toEqual([[grass[1].x, grass[1].y]]);
+
+ stage([layout(0, "solar_panel")]);
+ await solverState.carryOverSolves([]);
+ expect(solverStorage.loadSolve(ISLAND)).toBeNull();
+ });
+});
+
+/*
+ * The same keep-what-still-stands rule for a change made in Setup. The island
+ * on screen is re-rated as the change happens (`rescoreResult`); one off
+ * screen is re-rated by `restore()` when it is next opened, rather than
+ * discarded because its signature names the old roster.
+ */
+/*
+ * A stop only asks a run to end: the workers finish their pass, and the result
+ * is scored and filed after. A game import that changed the rules in that gap
+ * had the late result scored under the imported ones and filed over the
+ * record `carryOverSolves` had just re-filed.
+ */
+describe("a game import over a run still finishing", () => {
+ const proto = SolverWorkerClient.prototype as unknown as Record<
+ string,
+ unknown
+ >;
+ const original = proto.solveProgressive;
+
+ beforeEach(async () => {
+ store.clear();
+ layoutState.savedGrids = {};
+ layoutState.customIslands = [];
+ layoutState.activeTemplateId = ISLAND;
+ await layoutState.hydrate({});
+ configState.buildingUpgrades = { cooler1: 0, solar_panel: 0 };
+ configState.prestigeLevels = {};
+ configState.setAnomaly("none");
+ });
+
+ it("waits until the run has filed its result under its own rules", async () => {
+ let finish!: (variants: OptimizationResult[]) => void;
+ let stopped = false;
+ proto.solveProgressive = () => ({
+ promise: new Promise((r) => (finish = r)),
+ stop: () => void (stopped = true),
+ });
+ try {
+ const signature = solverState.solveSignature();
+ const run = solverState.runOptimizer();
+ let settled = false;
+ const settle = solverState
+ .settleOptimizer()
+ .then(() => void (settled = true));
+
+ await new Promise((r) => setTimeout(r, 0));
+ expect(stopped).toBe(true);
+ expect(settled).toBe(false);
+
+ finish([result(layout(0))]);
+ await settle;
+ expect(solverStorage.loadSolve(ISLAND)?.signature).toBe(signature);
+ await run;
+ } finally {
+ proto.solveProgressive = original;
+ }
+ });
+
+ it("returns at once when nothing is running", async () => {
+ await solverState.settleOptimizer();
+ expect(solverState.isOptimizing).toBe(false);
+ });
+});
+
+describe("opening an island after Setup changed", () => {
+ /** A solve filed for this terrain under the roster and rules set now. */
+ function stageHere(variants: PlacedBuilding[][]) {
+ stage(variants, 0, "none", solverState.solveSignature(mapGrid));
+ }
+
+ beforeEach(async () => {
+ store.clear();
+ layoutState.savedGrids = {};
+ layoutState.customIslands = [];
+ layoutState.activeTemplateId = ISLAND;
+ await layoutState.hydrate({});
+ configState.buildingUpgrades = { cooler1: 0, solar_panel: 0 };
+ configState.prestigeLevels = {};
+ configState.setAnomaly("none");
+ });
+
+ it("re-rates a solve whose roster changed while it was off screen", () => {
+ stageHere([layout(0)]);
+ configState.buildingUpgrades = { cooler1: 5, solar_panel: 0 };
+ configState.prestigeLevels = { absolute_zero: 2 };
+
+ solverState.restore();
+
+ expect(solverState.variants).toHaveLength(1);
+ expect(solverState.variants[0].placements[0].baseValue).toBe(
+ createPlacementAtLevel("cooler1", 0, 0, 5).baseValue,
+ );
+ expect(solverStorage.loadSolve(ISLAND)?.signature).toBe(
+ solverState.solveSignature(),
+ );
+ });
+
+ it("keeps a solve across an anomaly change, still saying where it was found", () => {
+ stageHere([layout(0)]);
+ configState.setAnomaly("cryo_nexus");
+
+ solverState.restore();
+
+ expect(solverState.variants).toHaveLength(1);
+ expect(solverState.resultAnomalyId).toBe("none");
+ });
+
+ it("drops the layouts that use a building Setup has locked", () => {
+ stageHere([layout(0, "solar_panel"), layout(1)]);
+ configState.buildingUpgrades = { cooler1: 0 };
+
+ solverState.restore();
+ expect(solverState.variants).toHaveLength(1);
+
+ // And the whole solve, once none of its layouts can still be built.
+ configState.buildingUpgrades = { cooler1: 0, solar_panel: 0 };
+ stageHere([layout(0, "solar_panel")]);
+ configState.buildingUpgrades = { cooler1: 0 };
+ solverState.restore();
+ expect(solverStorage.loadSolve(ISLAND)).toBeNull();
+ expect(solverState.variants).toHaveLength(0);
+ });
+
+ /*
+ * The first records were `terrain|roster`, from before there were anomalies,
+ * so the field an anomaly is read from in a record that names none is a
+ * piece of the terrain key. Read as an id, it left the solve forever "found
+ * under another timeline".
+ */
+ it("reads a solve from before anomalies as found under the base rules", () => {
+ stage([layout(0)], 0, "none", `${blueprintKey(mapGrid)}|cooler1:0`);
+ const saved = solverStorage.loadSolve(ISLAND)!;
+ delete saved.anomalyId;
+ solverStorage.saveSolve(saved);
+ configState.buildingUpgrades = { cooler1: 5, solar_panel: 0 };
+
+ solverState.restore();
+
+ expect(solverState.variants).toHaveLength(1);
+ expect(solverState.resultAnomalyId).toBe("none");
+ expect(solverStorage.loadSolve(ISLAND)?.anomalyId).toBe("none");
+ });
+
+ it("still discards a solve for terrain that has changed", () => {
+ stage([layout(1)], 0, "none", "another board|cooler1:0|none|");
+ solverState.restore();
+ expect(solverStorage.loadSolve(ISLAND)).toBeNull();
+ expect(solverState.variants).toHaveLength(0);
+ });
+});
diff --git a/src/lib/state/solvePersistence.test.ts b/src/lib/state/solvePersistence.test.ts
index 83ac1e1..f629b99 100755
--- a/src/lib/state/solvePersistence.test.ts
+++ b/src/lib/state/solvePersistence.test.ts
@@ -173,6 +173,53 @@ describe("persisted solve results", () => {
expect(activeSolve()).toBeNull();
});
+ describe("the run dialog", () => {
+ /**
+ * What the board and storage held at the moment the run was started, with
+ * the run itself stood in for: what is under test is what the dialog does
+ * before it, not the search.
+ */
+ async function startFromDialog(keepBest: boolean) {
+ const { uiState } = await import("./ui.svelte");
+ let seen: { onScreen: number | null; stored: boolean } | null = null;
+ const real = solverState.runOptimizer;
+ solverState.runOptimizer = async () => {
+ seen = {
+ onScreen: solverState.optimizationResult?.totalPower ?? null,
+ stored: activeSolve() !== null,
+ };
+ };
+ try {
+ uiState.startSolve(keepBest);
+ } finally {
+ solverState.runOptimizer = real;
+ }
+ return seen;
+ }
+
+ it("deletes a discarded solve before the run starts", async () => {
+ stageSolve(fakeResult(500));
+ solverState.restore();
+
+ // Gone from the board and from storage straight away, so a run stopped
+ // before it reports anything cannot hand the discarded layout back.
+ expect(await startFromDialog(false)).toEqual({
+ onScreen: null,
+ stored: false,
+ });
+ });
+
+ it("leaves a kept solve where it is", async () => {
+ stageSolve(fakeResult(500));
+ solverState.restore();
+
+ expect(await startFromDialog(true)).toEqual({
+ onScreen: 500,
+ stored: true,
+ });
+ });
+ });
+
it("keeps a solve when the user places a building by hand", () => {
stageSolve(fakeResult(500));
diff --git a/src/lib/state/solveVariants.test.ts b/src/lib/state/solveVariants.test.ts
index e01634a..ad5f0ce 100755
--- a/src/lib/state/solveVariants.test.ts
+++ b/src/lib/state/solveVariants.test.ts
@@ -10,7 +10,7 @@
* An app rule, like `solvePersistence.test.ts` — the engine solves
* for one layout and has nothing to say about any of it.
*/
-import { beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OptimizationResult, PlacedBuilding } from "../types";
// The storage helpers are SSR-guarded on `typeof window`, so a test running in
@@ -26,9 +26,14 @@ const store = new Map();
};
const { layoutState } = await import("./layout.svelte");
-const { solverState, chooseSolveVariants } = await import("./solver.svelte");
+const { solverState, chooseSolveVariants, composeIslandBests } =
+ await import("./solver.svelte");
const { solverStorage } = await import("../storage/storage");
+const { configState } = await import("./config.svelte");
const { SolverWorkerClient } = await import("../worker/workerClient");
+const { simulatePlacedBuildings } = await import("../simulation/simulator");
+const { createPlacementAtLevel } = await import("../data/placements");
+const { BUILDINGS } = await import("@reactor2/solver");
function placed(buildingId: string, x: number, y: number): PlacedBuilding {
return {
@@ -190,6 +195,91 @@ describe("what a finished run does to the shortlist", () => {
});
});
+describe("taking each island's best across two runs", () => {
+ /**
+ * A board of two islands, x < 100 and x >= 100, worth `a` and `b`. `shape`
+ * moves both islands' buildings, so boards of different shapes are distinct
+ * layouts on each island separately.
+ */
+ function twoIslands(a: number, b: number, shape: number): OptimizationResult {
+ const board = result(a + b, 0);
+ const row = (x0: number, power: number) =>
+ [0, 1, 2, 3, 4].map((k) => ({
+ ...placed("generator", x0 + shape * 5 + k, 0),
+ powerGenerated: k === 0 ? power : 0,
+ }));
+ board.placements = [...row(0, a), ...row(100, b)];
+ return board;
+ }
+ const islandOf = (p: PlacedBuilding) => (p.x < 100 ? 0 : 1);
+ const sum = (rows: PlacedBuilding[]) =>
+ rows.reduce((s, p) => s + p.powerGenerated, 0);
+ const shapeOf = (rows: PlacedBuilding[], island: number) =>
+ (rows.filter((p) => islandOf(p) === island)[0].x - island * 100) / 5;
+
+ /*
+ * The case it exists for: the fresh run wins on the total by finding a
+ * better large island, and would have taken a worse small one with it.
+ */
+ it("keeps the held island the fresh run did worse on", () => {
+ const held = [twoIslands(100, 200, 0)];
+ const fresh = [twoIslands(150, 180, 1)];
+ const composed = composeIslandBests(held, fresh, islandOf, sum)!;
+
+ expect(composed.map(sum)).toEqual([350]);
+ expect(shapeOf(composed[0], 0)).toBe(1);
+ expect(shapeOf(composed[0], 1)).toBe(0);
+ });
+
+ it("has nothing to add when one board already holds every best", () => {
+ const held = [twoIslands(100, 200, 0)];
+ expect(
+ composeIslandBests(held, [twoIslands(150, 200, 1)], islandOf, sum),
+ ).toBeNull();
+ expect(
+ composeIslandBests(held, [twoIslands(90, 190, 1)], islandOf, sum),
+ ).toBeNull();
+ });
+
+ it("is never asked to merge when nothing is held", () => {
+ expect(
+ composeIslandBests([], [twoIslands(1, 2, 0)], islandOf, sum),
+ ).toBeNull();
+ });
+
+ /*
+ * Every layout is upgraded, so a tied alternate on the island it was
+ * already best on survives as an alternate of the merged board.
+ */
+ it("upgrades the shortlist too, and drops the copies that makes", () => {
+ const held = [twoIslands(100, 200, 0), twoIslands(100, 200, 2)];
+ const fresh = [twoIslands(150, 180, 1), twoIslands(150, 180, 3)];
+ const composed = composeIslandBests(held, fresh, islandOf, sum)!;
+
+ // The fresh run's leader, upgraded, is the held leader upgraded: one board.
+ expect(composed.map(sum)).toEqual([350, 350, 350]);
+ expect(composed.map((c) => [shapeOf(c, 0), shapeOf(c, 1)])).toEqual([
+ [1, 0],
+ [1, 2],
+ [3, 0],
+ ]);
+ });
+
+ it("feeds a merge that beats both runs through as the new shortlist", () => {
+ const held = [twoIslands(100, 200, 0)];
+ const merged = composeIslandBests(
+ held,
+ [twoIslands(150, 180, 1)],
+ islandOf,
+ sum,
+ )!.map((placements) => ({ ...result(sum(placements), 0), placements }));
+
+ const chosen = chooseSolveVariants(held, 0, merged);
+ expect(chosen.defended).toBe(false);
+ expect(chosen.variants[0].totalPower).toBe(350);
+ });
+});
+
describe("a shortlist that survives a reload", () => {
/** Where the buildings can actually go, so the re-score has something to do. */
const grassAt = (n: number) => {
@@ -390,4 +480,142 @@ describe("what a re-run leaves on screen", () => {
expect(solverState.optimizationResult?.totalPower).toBe(500);
expect(solverState.variants.map((v) => v.totalPower)).toEqual([500]);
});
+
+ /*
+ * The merge as the app reaches it: two islands, each run winning one of
+ * them, and real scoring on the way out — the composite is re-scored on the
+ * board, so this is what proves it is a legal board worth the sum.
+ */
+ it("keeps the better of each island across the two runs", async () => {
+ // Two three-tile islands, rows 0 and 4, with water between.
+ layoutState.grid = ["GGG", "~~~", "~~~", "~~~", "GGG"].map((row, y) =>
+ [...row].map((c, x) => ({
+ x,
+ y,
+ type: c === "G" ? ("grass" as const) : ("water" as const),
+ })),
+ );
+ /** A reactor, generator and cooler along row `y`, the reactor at `tier`. */
+ const hub = (y: number, tier: number) => [
+ createPlacementAtLevel("solar_panel", 0, y, tier),
+ createPlacementAtLevel("generator", 1, y, 0),
+ createPlacementAtLevel("cooler1", 2, y, 5),
+ ];
+ const board = (rows: PlacedBuilding[]): OptimizationResult => {
+ const scored = simulatePlacedBuildings(layoutState.grid, BUILDINGS, rows);
+ const power = scored.reduce((sum, p) => sum + p.powerGenerated, 0);
+ return { ...result(power, 0), placements: scored };
+ };
+ const strong = board(hub(0, 5)).totalPower;
+ const weak = board(hub(0, 0)).totalPower;
+ expect(weak).toBeGreaterThan(0);
+ expect(strong).toBeGreaterThan(weak);
+
+ fake = [board([...hub(0, 5), ...hub(4, 0)])];
+ await solverState.runOptimizer();
+ // The two tie on the total, so the whole-board rules could only pool
+ // them; neither board on its own has both strong islands.
+ fake = [board([...hub(0, 0), ...hub(4, 5)])];
+ await solverState.runOptimizer({ keepBest: true });
+
+ expect(solverState.optimizationResult?.totalPower).toBeCloseTo(2 * strong);
+ });
+
+ describe("merging islands on a board that has moved on", () => {
+ /** Two three-tile islands, rows 0 and 4, with water between. */
+ const twoIslands = () =>
+ ["GGG", "~~~", "~~~", "~~~", "GGG"].map((row, y) =>
+ [...row].map((c, x) => ({
+ x,
+ y,
+ type: c === "G" ? ("grass" as const) : ("water" as const),
+ })),
+ );
+ /** A reactor, generator and cooler along row `y`. */
+ const hub = (y: number) => [
+ createPlacementAtLevel("solar_panel", 0, y, 5),
+ createPlacementAtLevel("generator", 1, y, 0),
+ createPlacementAtLevel("cooler1", 2, y, 5),
+ ];
+ /** The same row with no reactor, so nothing to convert: worth nothing. */
+ const idle = (y: number) => [
+ createPlacementAtLevel("generator", 1, y, 0),
+ createPlacementAtLevel("cooler1", 2, y, 5),
+ ];
+ const board = (rows: PlacedBuilding[]): OptimizationResult => {
+ const scored = simulatePlacedBuildings(layoutState.grid, BUILDINGS, rows);
+ const power = scored.reduce((sum, p) => sum + p.powerGenerated, 0);
+ return { ...result(power, 0), placements: scored };
+ };
+ const reactorRows = (v: OptimizationResult | null | undefined) =>
+ (v?.placements ?? [])
+ .filter((p) => p.buildingId === "solar_panel")
+ .map((p) => p.y)
+ .sort();
+
+ let roster: Record = {};
+ beforeEach(() => {
+ roster = { ...configState.buildingUpgrades };
+ configState.lockAll();
+ configState.setUpgradeLevel("solar_panel", 5);
+ configState.setUpgradeLevel("generator", 0);
+ configState.setUpgradeLevel("cooler1", 5);
+ layoutState.grid = twoIslands();
+ });
+ afterEach(() => {
+ while (configState.upgradePlan.length > 0) configState.removePlanStep(0);
+ configState.lockAll();
+ for (const [id, level] of Object.entries(roster))
+ configState.setUpgradeLevel(id, level);
+ });
+
+ /*
+ * Under a plan an island is judged by `evaluatePlan` on its placements
+ * alone, so the merge leans on that figure adding up across islands. A
+ * part-board it could not rate would read as zero and the merge would
+ * never fire.
+ */
+ it("takes each island's best under an upgrade plan too", async () => {
+ configState.addPlanStep("generator");
+ expect(configState.hasPlan).toBe(true);
+
+ fake = [board([...hub(0), ...idle(4)])];
+ await solverState.runOptimizer();
+ fake = [board([...idle(0), ...hub(4)])];
+ await solverState.runOptimizer({ keepBest: true });
+
+ expect(reactorRows(solverState.optimizationResult)).toEqual([0, 4]);
+ });
+
+ /*
+ * A held layout is the board at its own run. Paint under one of its
+ * buildings and that island is no longer something a new board can be
+ * built from: the merge would carry the building on the rock across.
+ *
+ * The building painted over is a spare cooler, so losing it costs the held
+ * island nothing — which is exactly when a merged board carrying it would
+ * win and be kept.
+ */
+ it("does not take an island from a held layout the terrain has left", async () => {
+ const wider = () => {
+ const grid = twoIslands();
+ grid[0].push({ x: 3, y: 0, type: "grass" });
+ for (const row of grid.slice(1))
+ row.push({ x: 3, y: row[0].y, type: "water" });
+ return grid;
+ };
+ layoutState.grid = wider();
+ const spare = createPlacementAtLevel("cooler1", 3, 0, 5);
+ fake = [board([...hub(0), spare, ...idle(4)])];
+ await solverState.runOptimizer();
+
+ layoutState.grid = wider();
+ layoutState.grid[0][3] = { x: 3, y: 0, type: "rock" };
+ fake = [board([...idle(0), ...hub(4)])];
+ await solverState.runOptimizer({ keepBest: true });
+
+ for (const v of solverState.variants)
+ expect(reactorRows(v)).not.toEqual([0, 4]);
+ });
+ });
});
diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts
index 0478f0c..5acb06b 100755
--- a/src/lib/state/solver.svelte.ts
+++ b/src/lib/state/solver.svelte.ts
@@ -1,5 +1,10 @@
-import type { OptimizationResult, PlacedBuilding } from "../types";
-import { DEFAULT_ANOMALY_ID, type AnomalyId } from "@reactor2/solver";
+import type { OptimizationResult, PlacedBuilding, Tile } from "../types";
+import {
+ DEFAULT_ANOMALY_ID,
+ getAnomaly,
+ type AnomalyDefinition,
+ type AnomalyId,
+} from "@reactor2/solver";
import {
SolverWorkerClient,
type SolveTaskHandle,
@@ -33,6 +38,7 @@ import { trackEvent } from "../utils/analytics";
import {
solverStorage,
uiStorage,
+ type SavedSolveData,
type StoredPlacement,
} from "../storage/storage";
import { configState } from "./config.svelte";
@@ -126,6 +132,78 @@ export function chooseSolveVariants(
return { variants: fresh.slice(0, limit), selected: 0, defended: false };
}
+/**
+ * Boards built from the best answer each island has had, across both runs.
+ *
+ * `chooseSolveVariants` weighs whole boards, and on a board of several islands
+ * that loses work: a re-run that finds a better large island and a worse small
+ * one wins on the total and takes the worse small island with it, although the
+ * held board's version is still sitting in the shortlist. Islands never
+ * interact, so any mix of one board's islands with another's is a legal board
+ * whose power is the sum of the parts — and taking the stronger side island by
+ * island is at least as good as either board.
+ *
+ * Every layout in either shortlist is upgraded this way, not only the leaders,
+ * so the arrangements a shortlist offers survive wherever they were already
+ * the best answer for their island. Each comes out at the same power, which is
+ * what makes the result a shortlist again.
+ *
+ * Null when one board already holds the best of every island — the merge
+ * would only rebuild it, and the plain whole-board rules say everything there
+ * is to say. `islandPower` is the figure the run maximised for those
+ * placements alone, so under an upgrade plan it is the power at the plan's end.
+ */
+export function composeIslandBests(
+ held: readonly OptimizationResult[],
+ fresh: readonly OptimizationResult[],
+ islandOf: (p: PlacedBuilding) => number,
+ islandPower: (placements: PlacedBuilding[]) => number,
+ limit: number = MAX_SOLVE_VARIANTS,
+): PlacedBuilding[][] | null {
+ if (held.length === 0 || fresh.length === 0) return null;
+
+ const boards = [...held, ...fresh].map((variant) => {
+ const byIsland = new Map();
+ for (const p of variant.placements) {
+ const key = islandOf(p);
+ const rows = byIsland.get(key);
+ if (rows) rows.push(p);
+ else byIsland.set(key, [p]);
+ }
+ return byIsland;
+ });
+ const islands = [...new Set(boards.flatMap((b) => [...b.keys()]))];
+ const power = boards.map(
+ (b) => new Map(islands.map((k) => [k, islandPower(b.get(k) ?? [])])),
+ );
+
+ // Which board holds each island's best answer.
+ const owner = new Map();
+ for (const k of islands) {
+ let best = 0;
+ for (let i = 1; i < boards.length; i++)
+ if (power[i].get(k)! > power[best].get(k)!) best = i;
+ owner.set(k, best);
+ }
+
+ /** Strictly below, and by more than the precision the figures carry. */
+ const falls = (i: number, k: number) => {
+ const mine = power[i].get(k)!;
+ const top = power[owner.get(k)!].get(k)!;
+ return mine < top && !powerTies(mine, top);
+ };
+ if (boards.some((_, i) => islands.every((k) => !falls(i, k)))) return null;
+
+ const composed: PlacedBuilding[][] = [];
+ for (let i = 0; i < boards.length && composed.length < limit; i++) {
+ const placements = islands.flatMap(
+ (k) => boards[falls(i, k) ? owner.get(k)! : i].get(k) ?? [],
+ );
+ if (isDistinctLayout(placements, composed)) composed.push(placements);
+ }
+ return composed;
+}
+
/**
* A layout stripped to what storage keeps: which building, where, and the tier
* it was placed at. Everything else is re-derived by scoring it — see
@@ -136,6 +214,22 @@ function toStoredPlacements(placements: PlacedBuilding[]): StoredPlacement[] {
}
/** The other direction: bare shape back to unscored placements. */
+/**
+ * The anomaly a stored solve's shapes were searched under. A record from before
+ * `anomalyId` existed carries it in its signature, second field from the end.
+ */
+function searchedAnomaly(saved: SavedSolveData): AnomalyId {
+ if (saved.anomalyId) return saved.anomalyId;
+ // Counted from the end: the terrain key in front is raw bytes and may hold
+ // a "|" of its own, while the roster, anomaly and research never do. A
+ // record older still is `terrain|roster`, from before there were anomalies,
+ // so that field is a piece of the terrain key — and `getAnomaly` reads
+ // anything it does not know as the base rules, which is what that solve was
+ // searched under.
+ const fields = saved.signature.split("|");
+ return getAnomaly(fields[fields.length - 2]).id;
+}
+
function restorePlacements(stored: StoredPlacement[]): PlacedBuilding[] {
return stored.map(([buildingId, x, y, baseValue]) =>
unscoredPlacement(buildingId, x, y, baseValue),
@@ -253,7 +347,7 @@ class SolverState {
*
* Persisted, because it is a standing answer to "how much wall-clock am I
* willing to give this", not a per-island decision. It deliberately does not
- * take part in `solveSignature()`: a layout found by ten short searches is
+ * take part in `solveSignature()`: a layout found by five short searches is
* the same kind of answer as one found by a single long one, so changing the
* mode does not make a stored solve stale.
*/
@@ -301,6 +395,9 @@ class SolverState {
#watchdog: ReturnType | null = null;
+ /** Settles once the run in flight has filed its result; null between runs. */
+ #runEnded: Promise | null = null;
+
/**
* The chosen shape of a run, as it runs under the active anomaly. Falls back
* rather than throwing on a stale id.
@@ -331,7 +428,7 @@ class SolverState {
*
* Not `attempts × budget`: the attempts are pool tasks and overlap, so the
* cost of a deep run depends on how many cores the browser reports. Printing
- * it is the whole reason the choice can be made honestly — "10 × 10s" says
+ * it is the whole reason the choice can be made honestly — "5 × 20s" says
* nothing about whether that means twenty seconds or a hundred.
*
* Takes the mode rather than reading the current one so the explainer can
@@ -363,10 +460,8 @@ class SolverState {
// carries the board's real terrain, so a neighbouring island's tiles sit
// inside it and counting them would inflate every budget estimate.
const tileCounts = islands.map((island) => island.tileCount);
- return estimateMakespanMs(
- taskDurationsMs(mode, tileCounts),
- defaultPoolSize(),
- );
+ const pool = defaultPoolSize();
+ return estimateMakespanMs(taskDurationsMs(mode, tileCounts, pool), pool);
}
/** The same figure for the mode a run would use right now. */
@@ -384,7 +479,11 @@ class SolverState {
/** Theoretical upper bound maximum power estimate for current grid and unlocked buildings. */
get estimatedMaxPower(): number {
- const grid = layoutState.grid;
+ return this.#maxPowerFor(layoutState.grid);
+ }
+
+ /** `estimatedMaxPower` for any board, not only the one on screen. */
+ #maxPowerFor(grid: Tile[][]): number {
if (!grid || !grid.length || !grid[0] || !grid[0].length) return 0;
const upgrades = configState.buildingUpgrades;
@@ -457,7 +556,7 @@ class SolverState {
* Public because it is a plain query — and because it is what a test needs
* to stage a stored solve without running a five-minute solve first.
*/
- solveSignature(): string {
+ solveSignature(grid: Tile[][] = layoutState.grid): string {
const roster = Object.entries(configState.buildingUpgrades)
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, level]) => `${id}:${level}`)
@@ -470,7 +569,7 @@ class SolverState {
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, level]) => `${id}:${level}`)
.join(",");
- return `${blueprintKey(layoutState.grid)}|${roster}|${configState.anomalyId}|${research}`;
+ return `${blueprintKey(grid)}|${roster}|${configState.anomalyId}|${research}`;
}
/**
@@ -488,12 +587,25 @@ class SolverState {
*/
restore() {
const templateId = layoutState.activeTemplateId;
- const saved = solverStorage.loadSolve(templateId);
-
- // A repainted board or a different roster is not a stale view of the
- // truth — it is a different question's answer.
- if (!saved || saved.signature !== this.solveSignature()) {
- if (saved) solverStorage.clearSolve(templateId);
+ let saved = solverStorage.loadSolve(templateId);
+
+ // A repainted board is a different question, and its answer goes. A
+ // roster, research or anomaly changed in Setup while this island was off
+ // screen is not: the shape still stands on this terrain, so it is re-rated
+ // the way `rescoreResult` re-rated the island that *was* on screen —
+ // otherwise every other island's solve was lost on its next open.
+ if (saved && saved.signature !== this.solveSignature()) {
+ const sameTerrain = saved.signature.startsWith(
+ `${blueprintKey(layoutState.grid)}|`,
+ );
+ if (sameTerrain && this.#refile(templateId, saved, layoutState.grid))
+ saved = solverStorage.loadSolve(templateId);
+ else {
+ solverStorage.clearSolve(templateId);
+ saved = null;
+ }
+ }
+ if (!saved) {
this.#reset();
return;
}
@@ -536,11 +648,65 @@ class SolverState {
* scored. `bound` is passed in rather than read per call: it is the same
* figure for every variant and computing it re-splits the whole grid.
*/
+ /**
+ * `composeIslandBests` over the held and fresh shortlists, scored — or null
+ * where the merge has nothing to add.
+ *
+ * The islands are split the way the run split them: the roster it searched
+ * decides whether a lone patch can be cooled, and the anomaly can decide
+ * whether there are islands at all. Under a shared cooling pool the board is
+ * one island, so a merge never finds anything to do. Placements on no island
+ * share one key, which keeps them with whichever board they came from.
+ */
+ #composeIslands(
+ held: OptimizationResult[],
+ fresh: OptimizationResult[],
+ grid: Tile[][],
+ unlocks: Record,
+ anomaly: AnomalyDefinition,
+ islandPower: (placements: PlacedBuilding[]) => number,
+ ): OptimizationResult[] | null {
+ // A held layout is the board as it stood at its own run, and terrain
+ // painted since may have put something under one of its buildings. The
+ // whole board defending itself is `chooseSolveVariants`' concern; taking
+ // one island of it into a new board is this one's, so only layouts still
+ // standing on grass are offered — the test `#refile` applies.
+ const standing = held.filter((v) =>
+ v.placements.every((p) => grid[p.y]?.[p.x]?.type === "grass"),
+ );
+ if (standing.length === 0) return null;
+
+ const width = grid[0]?.length ?? 0;
+ const islandAt = new Map();
+ const islands = splitGridIntoIslands(
+ grid,
+ canCoolDirectProducer(
+ getEffectiveBuildings(BUILDINGS, unlocks, configState.prestige),
+ ),
+ anomaly,
+ );
+ islands.forEach((island, i) =>
+ island.originalTileIndices.forEach((tile, k) => {
+ if (island.buildable[k] === 1) islandAt.set(tile, i);
+ }),
+ );
+
+ const composed = composeIslandBests(
+ standing,
+ fresh,
+ (p) => islandAt.get(p.y * width + p.x) ?? -1,
+ islandPower,
+ );
+ if (!composed) return null;
+ const bound = this.estimatedMaxPower;
+ return composed.map((placements) => this.#scoreLayout(placements, bound));
+ }
+
#scoreLayout(
placements: PlacedBuilding[],
bound: number,
+ grid: Tile[][] = layoutState.grid,
): OptimizationResult {
- const grid = layoutState.grid;
const scored = simulatePlacedBuildings(
grid,
BUILDINGS,
@@ -609,6 +775,95 @@ class SolverState {
solverStorage.clearSolve(templateId);
}
+ /**
+ * What importing the game does to every island's stored solve, decided per
+ * island against the board the import left it with.
+ *
+ * A game import changes the roster, the research and usually the terrain,
+ * and each of those is in `solveSignature()` — so left alone, `restore()`
+ * would discard every island's solve the next time it was opened, though
+ * most of them are still perfectly buildable. A solve can take minutes, so
+ * only what is actually invalid goes:
+ *
+ * - an island **still locked** in the game, whose board was reset to its map;
+ * - a solve **searched under another anomaly** — its shapes answer other
+ * rules, and re-rating them would pass them off as this timeline's;
+ * - a layout with a building on a tile the import made **unbuildable**, or
+ * one the imported roster **does not unlock** — dropped from the
+ * shortlist, and the solve with it once none survive.
+ *
+ * Everything else is kept: re-tiered to the imported unlocks, re-scored on
+ * the island's new board, and filed under the signature that board and
+ * roster now give, as `rescoreResult` does for the island on screen. Like it,
+ * this searches nothing, so a cleared obstacle shows up as room a re-run can
+ * use rather than as a changed layout.
+ */
+ async carryOverSolves(locked: readonly string[]) {
+ for (const templateId of solverStorage.solveIds()) {
+ const saved = solverStorage.loadSolve(templateId);
+ if (!saved) continue;
+ const grid = locked.includes(templateId)
+ ? null
+ : await layoutState.boardOf(templateId);
+ if (!grid || searchedAnomaly(saved) !== configState.activeAnomaly.id) {
+ solverStorage.clearSolve(templateId);
+ continue;
+ }
+
+ if (!this.#refile(templateId, saved, grid))
+ solverStorage.clearSolve(templateId);
+ }
+ }
+
+ /**
+ * Re-files a stored solve for `grid` under the roster, research and anomaly
+ * now in force, without searching again — or returns false when nothing in
+ * it can still be built there.
+ *
+ * A layout is dropped if a building stands on a tile that is no longer
+ * grass, or is one the roster no longer unlocks; the rest are re-tiered to
+ * the current unlocks, re-scored on `grid`, and written back under the
+ * signature `grid` now gives. The record keeps the anomaly its shapes were
+ * *searched* under, so a solve carried into another timeline still says so.
+ */
+ #refile(templateId: string, saved: SavedSolveData, grid: Tile[][]): boolean {
+ const upgrades = $state.snapshot(configState.buildingUpgrades);
+ const shapes = saved.variants?.length
+ ? saved.variants.map(restorePlacements)
+ : [saved.result.placements];
+ // Standing on grass, and something the roster still unlocks.
+ const buildable = (p: PlacedBuilding) =>
+ grid[p.y]?.[p.x]?.type === "grass" && p.buildingId in upgrades;
+ const kept = shapes
+ .map((placements, index) => ({ placements, index }))
+ .filter(({ placements }) => placements.every(buildable));
+ if (kept.length === 0) return false;
+
+ const bound = this.#maxPowerFor(grid);
+ const variants = kept.map(({ placements }) =>
+ this.#scoreLayout(
+ rebaseToUnlocks(placements, upgrades) ?? placements,
+ bound,
+ grid,
+ ),
+ );
+ const selected = Math.max(
+ 0,
+ kept.findIndex(({ index }) => index === (saved.selectedVariant ?? 0)),
+ );
+ this.#persistResult(
+ variants,
+ selected,
+ saved.durationMs,
+ saved.finishedAt,
+ templateId,
+ this.solveSignature(grid),
+ searchedAnomaly(saved),
+ saved.planKey ?? "",
+ );
+ return true;
+ }
+
/**
* Re-scores the layout on screen at the player's current unlock levels.
*
@@ -802,6 +1057,12 @@ class SolverState {
runAnomaly,
)?.power ?? 0)
: result.totalPower;
+ /** `rank` for one island's placements alone, read off the scored rows. */
+ const islandPower = (placements: PlacedBuilding[]): number =>
+ runPlan
+ ? (evaluatePlan(runGrid, placements, runPlan, runPrestige, runAnomaly)
+ ?.power ?? 0)
+ : placements.reduce((sum, p) => sum + p.powerGenerated, 0);
/*
* The bar a streaming snapshot has to clear to be worth putting on screen,
* and it is the same bar `chooseSolveVariants` will judge the finished run
@@ -865,6 +1126,8 @@ class SolverState {
// the run's island is still on screen.
let runError: string | null = null;
let donePower = 0;
+ let endRun!: () => void;
+ this.#runEnded = new Promise((resolve) => (endRun = resolve));
try {
// $state.snapshot is required before anything crosses postMessage:
@@ -958,11 +1221,26 @@ class SolverState {
!this.optimizationError
) {
// `previousVariants` is empty unless the user asked to keep the better
- // layout, so without that this always resolves to the fresh run.
+ // layout, so without that this always resolves to the fresh run. When
+ // each run won some islands, the boards made of every island's winner
+ // beat both, and stand in for the fresh run (`composeIslandBests`).
+ // Scored against the board on screen, so only while it is this run's.
+ const composed = onScreen()
+ ? this.#composeIslands(
+ previousVariants,
+ finalVariants,
+ runGrid,
+ runPlan
+ ? runPlan.target
+ : $state.snapshot(configState.buildingUpgrades),
+ runAnomaly,
+ islandPower,
+ )
+ : null;
const { variants, selected, defended } = chooseSolveVariants(
previousVariants,
previousIndex,
- finalVariants,
+ composed ?? finalVariants,
MAX_SOLVE_VARIANTS,
rank,
);
@@ -1031,6 +1309,11 @@ class SolverState {
this.resultPlanKey = previousPlanKey;
}
+ // Filed: a caller waiting on `settleOptimizer` may change the rules now.
+ // Before the analytics below, which must not be able to strand it.
+ this.#runEnded = null;
+ endRun();
+
// Every `solve_run` gets one of these, so a status other than `ok` is
// countable rather than inferred from a run that simply never reported.
const bound = this.estimatedMaxPower;
@@ -1057,12 +1340,35 @@ class SolverState {
}
}
+ /**
+ * `stopOptimizer`, then waits until the run has filed its result.
+ *
+ * A stop only asks: the workers finish the pass they are on, and the result
+ * is scored and filed after. That is harmless when the board merely leaves
+ * the screen, but not before the rules change under it — a game import that
+ * went on without waiting would have the late result scored against the
+ * imported board and roster, and filed over the record `carryOverSolves`
+ * had just re-filed. Waiting lets it land under the rules it was searched
+ * under, where the carry-over picks it up like any other stored solve.
+ */
+ async settleOptimizer(): Promise {
+ this.stopOptimizer();
+ await this.#runEnded;
+ }
+
/**
* The backstop is set against the run's **serial** cost — every attempt of
- * every island one after another — not against the estimate the button
- * prints. The estimate assumes a pool that is actually running in parallel;
- * a machine that reports eight cores and gives two would trip a watchdog set
- * to it, and cutting a run short is worse than letting a slow one finish.
+ * every island one after another, as `islandBudgetsS` divides them — not
+ * against the estimate the button prints. The estimate assumes a pool that is
+ * actually running in parallel; a machine that reports eight cores and gives
+ * two would trip a watchdog set to it, and cutting a run short is worse than
+ * letting a slow one finish.
+ *
+ * A run `fitBudgetsToPool` stretched spends more CPU than that, but only one
+ * whose every task has a worker of its own is stretched, and the search's
+ * deadlines are wall-clock: each task stops at its slice however busy the
+ * cores are, so the run still ends inside one attempt's budget. It would
+ * outlast this only if the browser started the workers one after another.
*/
#startWatchdog(mode: SolveMode) {
this.#stopWatchdog();
diff --git a/src/lib/state/ui.svelte.ts b/src/lib/state/ui.svelte.ts
index 559150b..8c4ab59 100755
--- a/src/lib/state/ui.svelte.ts
+++ b/src/lib/state/ui.svelte.ts
@@ -713,9 +713,18 @@ class UIState {
this.#beginSolve();
}
- /** Answers the dialog: `keepBest` defends the layout already on screen. */
+ /**
+ * Answers the dialog: `keepBest` defends the layout already on screen.
+ *
+ * Discarding deletes it here, from the screen and from storage, before the
+ * run starts rather than when its result lands. A layout the player chose
+ * to throw away should not stay on the board through a 20s run, and a run
+ * stopped before it reports anything would otherwise hand it back as if
+ * nothing had been chosen.
+ */
startSolve(keepBest: boolean) {
this.activeModal = null;
+ if (!keepBest) solverState.clearResult();
this.#beginSolve({ keepBest });
}
@@ -1056,8 +1065,9 @@ class UIState {
this.isImporting = true;
this.importError = null;
try {
- // The run belongs to a board and a roster this is about to replace.
- solverState.stopOptimizer();
+ // The run belongs to a board and a roster this is about to replace, so
+ // it has to have landed before either changes — see `settleOptimizer`.
+ await solverState.settleOptimizer();
configState.applyGameImport(
applyInProgress(resolved, this.gameImportMode),
resolved.anomalyId,
@@ -1067,11 +1077,12 @@ class UIState {
configState.buildingUpgrades,
resolved.lockedTemplateIds,
);
+ // Every stored solve was found under the old roster: keep each one the
+ // import left buildable, re-scored, before restore() reads the store.
+ await solverState.carryOverSolves(resolved.lockedTemplateIds);
this.activeModal = null;
this.importCode = "";
this.gameImport = null;
- // The board on screen may have been replaced, and every stored solve was
- // found under the old roster — restore() hangs the right one, as stale.
solverState.restore();
this.closeInspector();
this.recenterCanvas();
diff --git a/src/lib/storage/storage.ts b/src/lib/storage/storage.ts
index 7b7ec9f..70542e5 100755
--- a/src/lib/storage/storage.ts
+++ b/src/lib/storage/storage.ts
@@ -334,6 +334,9 @@ export const solverStorage = {
setItem(KEYS.SOLVE, records);
},
+ /** Every island that has a stored solve. */
+ solveIds: (): string[] => Object.keys(loadSolveRecords()),
+
/** Forgets one island's solve, or the whole store when it empties. */
clearSolve: (templateId: string): void => {
const records = loadSolveRecords();
diff --git a/src/lib/worker/solveModes.test.ts b/src/lib/worker/solveModes.test.ts
index ee82835..84b225b 100755
--- a/src/lib/worker/solveModes.test.ts
+++ b/src/lib/worker/solveModes.test.ts
@@ -1,7 +1,7 @@
/**
* What a run mode costs in wall-clock.
*
- * The figure on the mode control is the one thing that makes "10 × 10s" a
+ * The figure on the mode control is the one thing that makes "5 × 20s" a
* decision rather than a guess, and it is only honest if it models the pool the
* coordinator actually schedules onto. These cases pin the two ends of that:
* a pool wide enough to overlap the attempts, and one that is not.
@@ -17,63 +17,86 @@ import {
describe("taskDurationsMs", () => {
it("splits an attempt's budget across islands by tile count squared", () => {
// 900 : 100, where a linear split would give 3 : 1. See `islandBudgetsS`.
- const durations = taskDurationsMs(SOLVE_MODES.quick, [30, 10]);
+ // On one worker the run queues, so the slices have to sum to the budget.
+ const durations = taskDurationsMs(SOLVE_MODES.quick, [30, 10], 1);
expect(durations).toEqual([27_000, 3_000]);
});
+ /*
+ * With a worker per task the islands run side by side and the run lasts as
+ * long as its largest slice, so the slices are stretched until that one is
+ * the whole budget: a "30s" run takes 30s, and every island gets more.
+ */
+ it("stretches the split to the whole budget when the run fits the pool", () => {
+ const [big, small] = taskDurationsMs(SOLVE_MODES.quick, [30, 10], 8);
+ expect(big).toBe(30_000);
+ expect(small).toBeCloseTo(30_000 / 9);
+ expect(
+ estimateMakespanMs(taskDurationsMs(SOLVE_MODES.quick, [30, 10], 8), 8),
+ ).toBe(30_000);
+ });
+
+ it("leaves the split alone once the run has to queue", () => {
+ // 5 attempts x 2 islands is ten tasks for eight workers.
+ expect(taskDurationsMs(SOLVE_MODES.deep, [30, 10], 8).slice(0, 2)).toEqual([
+ 18_000, 2_000,
+ ]);
+ });
+
it("repeats the split once per attempt, attempt-major", () => {
// Attempt-major is what lets an early Stop still leave every island
// solved; island-major would starve the last one.
const durations = taskDurationsMs(
{ ...SOLVE_MODES.deep, attempts: 3 },
[1, 1],
+ 1,
);
- expect(durations).toEqual([5_000, 5_000, 5_000, 5_000, 5_000, 5_000]);
+ expect(durations).toEqual([10_000, 10_000, 10_000, 10_000, 10_000, 10_000]);
});
it("survives a board with no buildable tiles at all", () => {
- expect(taskDurationsMs(SOLVE_MODES.quick, [0])).toEqual([0]);
+ expect(taskDurationsMs(SOLVE_MODES.quick, [0], 8)).toEqual([0]);
});
});
describe("estimateMakespanMs", () => {
it("is the single task's own budget when there is only one", () => {
expect(
- estimateMakespanMs(taskDurationsMs(SOLVE_MODES.quick, [10]), 8),
+ estimateMakespanMs(taskDurationsMs(SOLVE_MODES.quick, [10], 8), 8),
).toBe(30_000);
});
it("overlaps a deep run's attempts across the pool", () => {
- // Ten 10s attempts of one island on seven workers: two rounds, because the
- // eighth task has to wait for a worker to come free.
- expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10]), 7)).toBe(
- 20_000,
- );
+ // Five 20s attempts of one island on four workers: two rounds, because the
+ // fifth task has to wait for a worker to come free.
+ expect(
+ estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10], 4), 4),
+ ).toBe(40_000);
});
it("charges a single-worker browser the full serial price", () => {
- expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10]), 1)).toBe(
- 100_000,
- );
- expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10]), 1)).toBe(
- 500_000,
- );
+ expect(
+ estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10], 1), 1),
+ ).toBe(100_000);
+ expect(
+ estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10], 1), 1),
+ ).toBe(500_000);
});
- it("keeps a fifty-attempt run to eight rounds on seven workers", () => {
- // 50 tasks over 7 workers is 7 apiece with one left over, so one worker
- // runs an eighth — 80s, not the 500s the mode's own arithmetic suggests.
- expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10]), 7)).toBe(
- 80_000,
- );
+ it("keeps a twenty-five-attempt run to four rounds on seven workers", () => {
+ // 25 tasks over 7 workers is 3 apiece with four left over, so four workers
+ // run a fourth — 80s, not the 500s the mode's own arithmetic suggests.
+ expect(
+ estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10], 7), 7),
+ ).toBe(80_000);
});
it("never reports less than the longest single task", () => {
// An island that owns most of the board gets most of every attempt's
// budget, and no amount of parallelism makes that one search shorter. The
// two scraps are held at the quarter-second floor, so it gets the rest.
- const durations = taskDurationsMs(SOLVE_MODES.quick, [90, 5, 5]);
- expect(estimateMakespanMs(durations, 8)).toBe(29_500);
+ const durations = taskDurationsMs(SOLVE_MODES.quick, [90, 5, 5], 2);
+ expect(estimateMakespanMs(durations, 2)).toBe(29_500);
});
it("is zero when there is nothing to schedule", () => {
@@ -93,7 +116,7 @@ describe("solveModeFor", () => {
expect(deep.attemptBudgetMs).toBeGreaterThan(
SOLVE_MODES.deep.attemptBudgetMs,
);
- expect(deep.shape).toBe(`10 × ${deep.attemptBudgetMs / 1000}s`);
+ expect(deep.shape).toBe(`5 × ${deep.attemptBudgetMs / 1000}s`);
});
it("reads an unknown id as the base rules, as getAnomaly does", () => {
diff --git a/src/lib/worker/solveModes.ts b/src/lib/worker/solveModes.ts
index deef54d..d8e3d62 100755
--- a/src/lib/worker/solveModes.ts
+++ b/src/lib/worker/solveModes.ts
@@ -5,11 +5,20 @@
* deterministic — so a single 30s walk is one sample from a distribution, and
* a long walk is not worth ten times a short one. Past the first few seconds
* the annealing temperature has fallen far enough that it mostly polishes the
- * basin it is already in. Ten independent 10s walks start from ten different
- * random streams, and the best of ten samples beats one longer sample often
+ * basin it is already in. Independent walks start from different random
+ * streams, and the best of several samples beats one longer sample often
* enough that it is worth offering as a choice rather than picking for the
* user.
*
+ * Not too short, though. A walk on a large island is still climbing well past
+ * 10s on a late roster, and `npm run survey` over all eight maps puts the
+ * average share of the bound at 91.7% for a 10s walk, 92.9% at 20s and 93.6%
+ * at 40s (full unlocks less cooler8 and ecto_compressor, no anomaly), and
+ * 95.3% against 96.1% at full unlocks. One 20s walk matches or beats the best
+ * of three 10s ones on both rosters, at two-thirds of the time — so at a fixed
+ * cost, five 20s walks beat ten 10s ones. The gain is on the large maps
+ * (147-180 tiles, 1-3 points); maps 1-3 sit where they will stay at 10s.
+ *
* It costs more wall-clock, which is why it is a choice at all. How much more
* depends on the machine: the attempts are independent tasks and go through
* the same worker pool the islands do, so a machine with cores to spare runs
@@ -18,7 +27,12 @@
* the button is pressed rather than after.
*/
-import { getAnomaly, islandBudgetsS, type AnomalyId } from "@reactor2/solver";
+import {
+ fitBudgetsToPool,
+ getAnomaly,
+ islandBudgetsS,
+ type AnomalyId,
+} from "@reactor2/solver";
export type SolveModeId = "quick" | "deep" | "max";
@@ -26,7 +40,7 @@ export interface SolveMode {
id: SolveModeId;
/** Short name for the control. */
label: string;
- /** What the run actually does, e.g. "10 × 10s". */
+ /** What the run actually does, e.g. "5 × 20s". */
shape: string;
/**
* How many independent searches **each island** gets. Their best is kept,
@@ -54,26 +68,27 @@ export const SOLVE_MODES: Record = {
deep: {
id: "deep",
label: "Deep",
- shape: "10 × 10s",
- attempts: 10,
- attemptBudgetMs: 10_000,
+ shape: "5 × 20s",
+ attempts: 5,
+ attemptBudgetMs: 20_000,
},
/**
- * The same 10s attempt as `deep`, five times over.
+ * The same 20s attempt as `deep`, five times over.
*
- * Sampling has diminishing returns — the best of fifty draws beats the best
- * of ten by much less than ten beat one — so this is the mode for a board
- * being settled rather than explored, and it is priced accordingly. It earns
- * its place mostly on the shortlist: fifty walks past the same optimum find
- * far more of the arrangements that tie it than ten do, and which of those
- * is nicest to build is the one judgement the solver cannot make.
+ * Sampling has diminishing returns — the best of twenty-five draws beats the
+ * best of five by much less than five beat one — so this is the mode for a
+ * board being settled rather than explored, and it is priced accordingly. It
+ * earns its place mostly on the shortlist: more walks past the same optimum
+ * find more of the arrangements that tie it, and which of those is nicest to
+ * build is the one judgement the solver cannot make. It was fifty 10s walks
+ * at the same cost, which found more ties and less power.
*/
max: {
id: "max",
label: "Max",
- shape: "50 × 10s",
- attempts: 50,
- attemptBudgetMs: 10_000,
+ shape: "25 × 20s",
+ attempts: 25,
+ attemptBudgetMs: 20_000,
},
};
@@ -94,16 +109,33 @@ export const DEFAULT_SOLVE_MODE: SolveModeId = "quick";
* unlocks it comes back 1.2% *below* them. 20s recovers nearly all of it; 30s
* and 60s add a few tenths more. Best of ten 10s walks beats one 10s walk, but
* best of ten 20s walks beats it again by 1-2 points on every roster, so the
- * attempt is lengthened rather than the count raised. Doubling gives Deep and
- * Max their 20s, and Quick goes along so a mode keeps one meaning.
+ * attempt is lengthened rather than the count raised. Those figures were taken
+ * against 10s attempts; doubling now gives Deep and Max 40s, and Quick goes
+ * along so a mode keeps one meaning.
+ *
+ * Both Tidals were measured too and gain no more from a longer walk than the
+ * base rules do (legacy ×1.67: 90.2% / 91.5% / 91.9% of the bound at 10 / 20 /
+ * 40s), so the longer default covers them and they take no factor here.
*/
-const ATTEMPT_BUDGET_SCALE: Partial> = {
- singularity_isolation: 2,
+const ATTEMPT_BUDGET_SCALE: Partial<
+ Record
+> = {
+ singularity_isolation: {
+ scale: 2,
+ reason:
+ "A generator's output depends on its neighbours there, so a walk needs " +
+ "the extra time to find layouts that keep them apart.",
+ },
};
/** The factor `solveModeFor` applies under `anomalyId`; 1 under most rules. */
export function attemptBudgetScale(anomalyId: string): number {
- return ATTEMPT_BUDGET_SCALE[getAnomaly(anomalyId).id] ?? 1;
+ return ATTEMPT_BUDGET_SCALE[getAnomaly(anomalyId).id]?.scale ?? 1;
+}
+
+/** Why `attemptBudgetScale` is above 1 under `anomalyId`, for the mode dialog. */
+export function attemptBudgetReason(anomalyId: string): string {
+ return ATTEMPT_BUDGET_SCALE[getAnomaly(anomalyId).id]?.reason ?? "";
}
/**
@@ -166,15 +198,20 @@ export function estimateMakespanMs(
* Shared by the estimate and by the coordinator's own ordering, so the figure
* on the button cannot describe a schedule the run does not use. The split
* within an attempt is `islandBudgetsS`, the one `planSolve` hands the
- * coordinator, for the same reason.
+ * coordinator, stretched by `fitBudgetsToPool` as the coordinator stretches
+ * it, for the same reason.
*/
export function taskDurationsMs(
mode: SolveMode,
islandTileCounts: readonly number[],
+ poolSize: number,
): number[] {
- const perAttempt = islandBudgetsS(
- islandTileCounts,
- mode.attemptBudgetMs / 1000,
+ const attemptS = mode.attemptBudgetMs / 1000;
+ const perAttempt = fitBudgetsToPool(
+ islandBudgetsS(islandTileCounts, attemptS),
+ attemptS,
+ mode.attempts,
+ poolSize,
).map((s) => s * 1000);
const durations: number[] = [];
for (let attempt = 0; attempt < mode.attempts; attempt++) {
diff --git a/src/lib/worker/solverCoordinator.test.ts b/src/lib/worker/solverCoordinator.test.ts
index 95e6aad..d21e4d0 100755
--- a/src/lib/worker/solverCoordinator.test.ts
+++ b/src/lib/worker/solverCoordinator.test.ts
@@ -42,7 +42,17 @@ const dispatched: IslandWorkerRequest[] = [];
* power the caller chooses per task id, so an "attempt" can be made to win,
* tie or lose deterministically.
*/
-function installFakeWorker(powerFor: (taskId: string) => number) {
+function installFakeWorker(
+ powerFor: (taskId: string) => number,
+ /**
+ * Progress powers each task streams, all at once, before it finishes —
+ * and how long after that it finishes. Omitted, a task only finishes.
+ */
+ stream: { progress: number[]; doneAfterMs: number } = {
+ progress: [],
+ doneAfterMs: 0,
+ },
+) {
const listeners = new Map