From c1925fd535c2341284a555049089c99193960fe1 Mon Sep 17 00:00:00 2001
From: OGMods <3816927+OGMods@users.noreply.github.com>
Date: Thu, 15 Oct 2026 01:37:22 +0300
Subject: [PATCH 1/5] Tighten the Tidal bound and carry its inland crossing
The two-class bound under a terrain bonus credited every generator with
the eight-neighbour cap at the shore rating, as if each were ringed by
boosted reactors and coolers. Only tiles one step from the coast can be.
Once the cap binds - generator7 over any reactor short of the top tiers,
where a ring of eight feeds about 65% of its rating - that was slack.
Each generator is now held to what its own neighbours can feed, counted
by class (shore or inland). On a late roster (full unlocks less cooler8
and ecto_compressor, precursor_pyramid tier 3) the Tidal bound drops
0.1-1.8% across the shipped maps; full unlocks and weaker rosters do not
move. A binding-cap island with hand-built and 300 arbitrary layouts
pins it.
With intake no longer linear the inland reactor split had to be found
by bisection, for every shore split of every engine split: ~100ms a call
on Event Horizon and Entropy Isles, read several times per render. Each
shore reactor added raises the heat side and lowers the intake side, so
the crossing only moves down; it is now carried from one shore split to
the next and galloped down from there. Both sides stay monotone in
floating point, so it finds the same count a fresh bisection did and
every map's bound is bit-identical. 946ms to 18.5ms across all maps,
faster than before the cap was added.
---
packages/solver/src/solver/island.ts | 190 ++++++++++++++++++++++-----
packages/solver/tests/island.test.ts | 53 ++++++++
2 files changed, 209 insertions(+), 34 deletions(-)
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/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.
From c0474d06dbc1c7538c895a6ac59dece302585a40 Mon Sep 17 00:00:00 2001
From: OGMods <3816927+OGMods@users.noreply.github.com>
Date: Thu, 15 Oct 2026 01:37:30 +0300
Subject: [PATCH 2/5] Lengthen Deep and Max attempts, and give Quick its whole
budget
Deep is now 5 x 20s and Max 25 x 20s, at the same cost as 10 x 10s and
50 x 10s. A walk on a large island is still climbing well past 10s on a
late roster: over all eight maps the search reaches 91.7% of the bound at
10s, 92.9% at 20s and 93.6% at 40s (late roster, no anomaly), 95.3% and
96.1% at full unlocks, and one 20s walk matches the best of three 10s
ones at two-thirds of the time. Both Tidals gain no more than the base
rules, so they take no attempt factor; Singularity keeps its 2x. The mode
dialog's note now gives each anomaly's own reason.
Islands run side by side, so a run whose every task has a worker of its
own lasts as long as its largest slice, not the 30s Quick advertises -
16-24s on boards of two large islands. fitBudgetsToPool, now in the
solver package beside islandBudgetsS, stretches the slices until the
largest is the whole budget when the run fits the pool; a run that
queues keeps the summed split. The coordinator, the run estimate and the
CLI's pooled run all use it, so the CLI's figures match what the app's
Quick gets on the same board. The survey keeps the summed split - it
measures one attempt's slices, which is what a queued Deep or Max spends.
The mode dialog, README, CLI docstring and docs/SOLVER.md are brought up
to the new figures.
---
README.md | 18 ++--
docs/SOLVER.md | 11 ++-
packages/solver/bin/solve.ts | 16 +++-
packages/solver/scripts/gapSurvey.ts | 6 +-
packages/solver/src/solver/solver.ts | 29 ++++++
packages/solver/tests/pipeline.test.ts | 16 +++-
.../modals/SolveModeInfoModal.svelte | 14 +--
.../sidebar/SolveModeSelector.svelte | 6 +-
src/lib/state/CLAUDE.md | 2 +-
src/lib/state/solver.svelte.ts | 6 +-
src/lib/worker/solveModes.test.ts | 73 +++++++++------
src/lib/worker/solveModes.ts | 89 +++++++++++++------
src/lib/worker/solverCoordinator.ts | 18 ++--
13 files changed, 214 insertions(+), 90 deletions(-)
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..ea41625 100644
--- a/docs/SOLVER.md
+++ b/docs/SOLVER.md
@@ -840,9 +840,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/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/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/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/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/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..089603d 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.
diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts
index 0478f0c..b63e95f 100755
--- a/src/lib/state/solver.svelte.ts
+++ b/src/lib/state/solver.svelte.ts
@@ -363,10 +363,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. */
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.ts b/src/lib/worker/solverCoordinator.ts
index e8a1e81..41b1065 100755
--- a/src/lib/worker/solverCoordinator.ts
+++ b/src/lib/worker/solverCoordinator.ts
@@ -7,7 +7,7 @@ import {
} from "@reactor2/solver";
import { buildSolveVariants } from "./variants";
import { IslandBest } from "./islandBest";
-import { countGrassTiles } from "@reactor2/solver";
+import { countGrassTiles, fitBudgetsToPool } from "@reactor2/solver";
import type { IslandSolution } from "@reactor2/solver";
import type {
BuildingDefinition,
@@ -66,7 +66,8 @@ export function defaultPoolSize(): number {
* Runs one island-search per worker, mirroring the process pool the reference
* pipeline uses: islands cannot influence each other, so wall-clock time
* becomes ~max(island budget) instead of the sum of them, and every island
- * still gets its full share of the budget (`islandBudgetsS`).
+ * still gets its full share of the budget (`islandBudgetsS`) — stretched to
+ * the whole of it when the run fits the pool at once (`fitBudgetsToPool`).
*
* A multi-attempt run rides the same queue. Nothing about a second search of
* an island differs from a first, so `attempts × islands` tasks go into the
@@ -109,11 +110,16 @@ export class SolverCoordinator {
return [createEmptyResult(grid?.[0]?.length ? countGrassTiles(grid) : 0)];
this.ensurePool();
- const solutions = await this.runQueue(
- plan,
- Math.max(1, options?.attempts ?? 1),
- options,
+ const attempts = Math.max(1, options?.attempts ?? 1);
+ // A run whose every task has a worker of its own takes its largest slice,
+ // not the sum, so the slices are stretched to the whole budget.
+ plan.budgetsS = fitBudgetsToPool(
+ plan.budgetsS,
+ timeBudgetS,
+ attempts,
+ this.poolSize,
);
+ const solutions = await this.runQueue(plan, attempts, options);
return buildSolveVariants(plan, solutions);
}
From 966b3def6567c6ef2eeabc4fd39b80a1b8f48921 Mon Sep 17 00:00:00 2001
From: OGMods <3816927+OGMods@users.noreply.github.com>
Date: Thu, 15 Oct 2026 01:37:16 +0300
Subject: [PATCH 3/5] Keep each island's best solve across re-runs, imports and
Setup changes
"Keep the better layout" compared whole boards, so on a board of several
islands a re-run that found a better large island won on the total and
brought a worse small island with it. Islands never interact, so
composeIslandBests now builds boards from each island's best answer
across the held and fresh shortlists, and those stand in for the fresh
run before the whole-board comparison. It returns null when one board
already holds every best (and under a shared cooling pool, which is one
island). Under an upgrade plan islands are compared on the power after
the plan. Only held layouts still standing on grass are offered, so
terrain painted since cannot carry a building onto rock. A two-island
board driven through runOptimizer pins the merge.
A game import changes the roster, the research and usually the terrain,
all in a solve's signature, so every island's solve was discarded though
most were still buildable. carryOverSolves now runs before restore() on
a game import and decides per island: a solve is dropped for an island
still locked in the game or searched under another anomaly; a layout is
dropped if an imported obstacle covers one of its buildings or the
roster no longer unlocks one. Everything else is re-tiered, re-scored on
its new board and re-filed under its new signature. restore() does the
same when only the roster, research or anomaly differ, so an island
opened after a Setup change keeps its solve, re-rated. Changed terrain
is still discarded. settleOptimizer stops a run in flight and waits for
it to file, so a late result cannot land over the carried-over record.
A pre-anomaly record's unknown anomaly id now resolves to the base rules.
Every worker reports on its own clock, so a pool of eight handed the
main thread up to eight full boards a second. On a page slowed 6x a Deep
run kept the main thread busy 9.5s of 11s, and a main thread that falls
behind stops updating the board and never forwards Stop. The coordinator
now hands the page at most one update per report interval, still drawing
the best anything has reported, and cancels a pending update when the
run settles: 4.3s busy, 22 long tasks instead of 87.
"Discard this layout" now removes it from the board and from storage as
the run starts, so a run stopped before it reports cannot hand it back.
---
src/lib/components/modals/SolveModal.svelte | 4 +-
src/lib/state/CLAUDE.md | 52 ++-
src/lib/state/layout.svelte.ts | 15 +
src/lib/state/solveCarryOver.test.ts | 321 ++++++++++++++++++
src/lib/state/solvePersistence.test.ts | 47 +++
src/lib/state/solveVariants.test.ts | 232 ++++++++++++-
src/lib/state/solver.svelte.ts | 348 ++++++++++++++++++--
src/lib/state/ui.svelte.ts | 21 +-
src/lib/storage/storage.ts | 3 +
src/lib/worker/solverCoordinator.test.ts | 59 +++-
src/lib/worker/solverCoordinator.ts | 39 ++-
11 files changed, 1102 insertions(+), 39 deletions(-)
create mode 100644 src/lib/state/solveCarryOver.test.ts
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/state/CLAUDE.md b/src/lib/state/CLAUDE.md
index 089603d..0b4b02f 100644
--- a/src/lib/state/CLAUDE.md
+++ b/src/lib/state/CLAUDE.md
@@ -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 b63e95f..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
@@ -382,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;
@@ -455,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}`)
@@ -468,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}`;
}
/**
@@ -486,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;
}
@@ -534,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,
@@ -607,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.
*
@@ -800,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
@@ -863,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:
@@ -956,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,
);
@@ -1029,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;
@@ -1055,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/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