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 void>>(); class FakeWorker { @@ -58,6 +68,19 @@ function installFakeWorker(powerFor: (taskId: string) => number) { if (req.type !== "SOLVE_ISLAND") return; dispatched.push(req); const placements: PlacedBuilding[] = []; + setTimeout(() => { + for (const powerOutput of stream.progress) { + const msg = { + data: { + id: req.id, + type: "ISLAND_PROGRESS", + placements, + powerOutput, + }, + } as MessageEvent; + for (const fn of listeners.get(this.#self) ?? []) fn(msg); + } + }, 0); setTimeout(() => { const msg = { data: { @@ -69,7 +92,7 @@ function installFakeWorker(powerFor: (taskId: string) => number) { }, } as MessageEvent; for (const fn of listeners.get(this.#self) ?? []) fn(msg); - }, 0); + }, stream.doneAfterMs); } terminate() { listeners.delete(this.#self); @@ -169,6 +192,38 @@ describe("SolverCoordinator", () => { expect(results[0].totalPower).toBe(90); }); + it("hands the page at most one progress update per report interval", async () => { + // Four workers each stream twenty reports at once: eighty messages, which + // the page used to receive as eighty boards. + installFakeWorker(() => 100, { + progress: Array.from({ length: 20 }, (_, i) => i + 1), + doneAfterMs: 300, + }); + const coordinator = new SolverCoordinator(4); + + const updates: number[] = []; + const solving = coordinator.solve(board(4), [...BUILDINGS], UPGRADES, 10, { + attempts: 1, + reportIntervalMs: 100, + onProgress: (result) => updates.push(result.totalPower), + }); + await solving; + const atSettle = updates.length; + // Long enough for any pending trailing update to have fired. + await new Promise((resolve) => setTimeout(resolve, 250)); + coordinator.destroy(); + + // The first report goes straight through, and the rest of the burst + // arrives as one trailing update a window later, carrying the best each + // island had reported by then rather than the first figure it sent. + expect(updates.length).toBeGreaterThan(1); + expect(updates.length).toBeLessThanOrEqual(5); + expect(updates[1]).toBe(4 * 20); + // Nothing after the run settled: a late update would draw a snapshot + // over the finished result. + expect(updates.length).toBe(atSettle); + }); + it("ends the run when Stop leaves tasks still queued", async () => { // One worker and thirty tasks: most of them never get dispatched, and the // run has to stop counting on messages they will never send. diff --git a/src/lib/worker/solverCoordinator.ts b/src/lib/worker/solverCoordinator.ts index 41b1065..c329a4f 100755 --- a/src/lib/worker/solverCoordinator.ts +++ b/src/lib/worker/solverCoordinator.ts @@ -174,9 +174,41 @@ export class SolverCoordinator { let remaining = tasks.length; let stopSignaled = false; - const emitProgress = () => { + /* + * Progress reaches the page at most once per report interval, however + * many workers are reporting. + * + * Every worker reports on its own clock, so a pool of eight hands the + * main thread up to eight boards a second, and each one is a full board + * rebuilt, re-scored and redrawn there. On a fast machine that is noise; + * on a page slowed 6x it kept the main thread 85% busy with single tasks + * up to 0.7s, and a main thread that cannot keep up is one that 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. The + * workers' rate does not slow with the machine; this does, so it is + * capped here. `display` keeps absorbing every report in between, so what + * is drawn is always the best anything has reported so far. + * + * A trailing update carries whatever arrived inside the window, and it is + * cancelled when the run settles: one landing after the finished result + * would draw a snapshot of a search over the answer it produced. + */ + const progressIntervalMs = options?.reportIntervalMs ?? 1000; + let lastProgressMs = -Infinity; + let progressTimer: ReturnType | null = null; + let settled = false; + const flushProgress = () => { + progressTimer = null; + if (settled) return; + lastProgressMs = performance.now(); options?.onProgress?.(buildOptimizationResult(plan, display)); }; + const emitProgress = () => { + if (!options?.onProgress || settled || progressTimer !== null) return; + const waitMs = lastProgressMs + progressIntervalMs - performance.now(); + if (waitMs <= 0) flushProgress(); + else progressTimer = setTimeout(flushProgress, waitMs); + }; /** Monotonic: only a layout that beats what is drawn replaces it. */ const show = ( @@ -191,6 +223,11 @@ export class SolverCoordinator { return new Promise((resolve) => { const settle = () => { + settled = true; + if (progressTimer !== null) { + clearTimeout(progressTimer); + progressTimer = null; + } // An island whose every attempt errored has no finished answer; the // last thing it streamed is better than nothing, and better than the // empty board it would otherwise contribute. From 63a5cd65e9cfca8ad286ee1002b42979ce1bbd76 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:44:00 +0300 Subject: [PATCH 4/5] Cut the seed, repair and distribution costs; size the seed for Singularity Seed construction re-simulated every generator combination for every stale tile on every round; a shared fit depends only on the neighbourhood's shape under a uniform or role-wide rating, so it is memoised by that shape and the seed is 3.5x faster, bit for bit. greedyPolish re-sweeps only tiles within two of an applied move and re-simulates a cached winner before applying it: a wash at 15s per map, +5-9% under Tidal and +16% on Shadowspire under Singularity at 5s, where the polish share binds. The distribution's repair ends each search when the first consumer with room is enqueued rather than dequeued (the same path, the queue being FIFO) and keeps a per-component list of suppliers with supply left: 17% off a Cryo whole-board simulation, 9% off the base rules, bit-identical over 5054 random instances and the fixtures. The collector's tie check drops a redundant key string, targetCompositions keeps only its top three while enumerating, Singularity's layout copy folds into the scan loop, and the post-deadline cleanup yields between alternates. Under Singularity the seed now sizes each hub at the generator's isolated rating and claims no hub whose generator would stand beside another, and solveIsland starts from whichever of that seed and the plain one repairs higher: a wash at 15s, +14% and +18% on the two largest maps at 5s, nothing lost at generator7 tier 3 or under any other rule. Reheating reclaim rounds to a tenth of the walk temperature and an un-crowding move in the walk were both measured as noise and left out; the comments record them. `npm run profile` is the stage-cost instrument behind all of this. goldenLayoutsFullTier.test.ts is reformatted. --- docs/SOLVER.md | 35 +- package.json | 3 +- packages/solver/CLAUDE.md | 34 +- packages/solver/package.json | 1 + packages/solver/scripts/profile.ts | 273 +++++++++++ packages/solver/src/solver/alternates.ts | 7 +- packages/solver/src/solver/context.ts | 8 +- packages/solver/src/solver/distribution.ts | 99 ++-- packages/solver/src/solver/placementSearch.ts | 441 +++++++++++++++--- packages/solver/src/solver/simulate.ts | 10 +- .../tests/goldenLayoutsFullTier.test.ts | 24 +- 11 files changed, 807 insertions(+), 128 deletions(-) create mode 100644 packages/solver/scripts/profile.ts diff --git a/docs/SOLVER.md b/docs/SOLVER.md index ea41625..4fa516d 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -14,7 +14,8 @@ packages/solver/ src/utils/ the game's number ladder src/grid.ts tile predicates and the ASCII alphabet the tests are written in bin/ the CLI (`npm run solve`) - scripts/ the fixture exporter (`npm run fixtures`) + scripts/ the fixture exporter (`npm run fixtures`), the gap survey (`npm run survey`) + and the stage profiler (`npm run profile`) tests/ vitest, run by the repo's `npm test` fixtures/ golden fixtures, committed ``` @@ -499,6 +500,27 @@ three. Nothing came back lower anywhere. `anomalies.test.ts` pins the seed on a scraps: every cooler on a scrap and none beside a generator under the pool, the reverse under the base rules, and a seed that simulates alive. +**Under Singularity the seed is sized for the rule, and the plain seed is kept beside it.** Sized +from the plain roster, a full-unlock hub is one ecto reactor filling one generator, and the "shared" +fit stands three generators round one reactor — every one of them crowded, on a board where an +isolated generator takes two reactors and makes four times the power. That seed scored 32% of the +Singularity bound on Event Horizon where the base seed scores 60% of its own, and the first repair +had to double it. So under a generator isolation `constructSeed` sizes each hub fit with the +generator at its `isolated` rating (the plain entry is still what is written, and `simulateIsland` +rates it from its neighbours as always), and claims no hub whose generator would stand beside one. +After the first repair that seed reads 78–88% of the bound on the eight largest islands against the +plain one's 50–64%. `solveIsland` builds and repairs the plain-sized seed too and starts from +whichever repairs higher, the plain one on a tie — judged after the repair rather than before, +because on Entropy Isles' 20-tile island both repair to the same five full hubs and only the plain +one's looser arrangement leads the later stages to the six-generator optimum (2.54AD against 2.36AD +at its 0.3s budget). The plain repair is given the time the sized one took, so this costs at most +one more repair. Measured with the survey: at 15s × 5 runs a wash, −1.5% to +2.2% per map; at 5s × 3 +runs Event Horizon +14% and Shadowspire +18%, four maps +2–4%, Ash Bay and Magma Rift 4% and 1% +lower inside their bands; with generator7 at tier 3 every map inside 0.8%. An un-crowding move in +the walk — a crowded generator swapped onto a tile with no generator beside it — was measured at the +same time and came back inside the noise band on every map (−2.0% to +1.7% at 15s × 3), so it is not +in. + **Two calibrations were measured under the rules and left as they are.** `hillClimb`'s `moveScale` — the figure the annealing temperature is derived from — is read off the **plain** roster, so under a rule that rates a tile above it the walk runs colder than intended, up to `multiplier` @@ -550,10 +572,13 @@ property of the rules in force rather than of the grid, so it cannot be applied Two shapes in it are load-bearing and look like details: -- A supplier expands **every** unvisited adjacent consumer instead of shortcutting to the sink - when it finds one with room. The shortcut marks later consumers visited and can pick a - different equal-length augmenting path — same maximum flow, different split, and the split - decides which buildings clear their cooling. +- The sink is reached by the **first** consumer with room to be enqueued, through the supplier + that enqueued it — the queue is FIFO, so that is the one Unity's consumer node connects from. + The search ends at that enqueue, which saves the expansions in between and nothing else. A + supplier must never pick the sink on its own terms (scan its edge list for a consumer with + room, or mark later consumers visited without enqueuing them): that can pick a different + equal-length augmenting path — same maximum flow, different split, and the split decides + which buildings clear their cooling. - Supply and demand are counted **down** rather than accumulated up, because `cap - sent` is not bit-identical to a decremented remainder and the fixtures assert exactly. diff --git a/package.json b/package.json index 1fd1cca..a37ce3b 100755 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "build:solver": "npm run build --workspace @reactor2/solver", "solve": "npm run solve --workspace @reactor2/solver --", "fixtures": "npm run fixtures --workspace @reactor2/solver --", - "survey": "npm run survey --workspace @reactor2/solver --" + "survey": "npm run survey --workspace @reactor2/solver --", + "profile": "npm run profile --workspace @reactor2/solver --" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.2.0", diff --git a/packages/solver/CLAUDE.md b/packages/solver/CLAUDE.md index a95312b..60f988e 100644 --- a/packages/solver/CLAUDE.md +++ b/packages/solver/CLAUDE.md @@ -159,7 +159,7 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it, at 98% of its bound — is unchanged. The `coolerMultiplier` has to be earned back before any of that shows. - **The seed is the one stage that branches on the rule.** Under a pool + **The seed branches on the rule.** Under a pool `constructSeed` builds every hub engine-first — a phantom cooler sizes the fit, so the neighbour slots all go to reactors — charges the cooling as a board-wide debt in each candidate's rank, reserves the tiles for it, and pays @@ -245,6 +245,16 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it, The neighbour scan reads the _original_ placement, since the test is on what a neighbour is and no rating changes that — so there is no order to get right. + **The seed branches on this rule too.** Sized from the plain roster, a + full-unlock hub is one reactor filling one generator and the shared fit + stands three generators round one reactor, all crowded; that seed scored 32% + of the bound on Event Horizon. Under a generator isolation `constructSeed` + sizes each hub with the generator at `isolated` and claims no hub whose + generator would stand beside one, and `solveIsland` repairs the plain-sized + seed too and starts from whichever repairs higher, the plain one on a tie. + `docs/SOLVER.md` has the measurements: a wash at 15s, +14-18% on the two + largest maps at 5s. + **The bonus is on a capacity, which is why the rule is close to power-neutral at full unlocks.** `isolated` scales a generator's _intake_, and a generator fed by the reactors it already had simply fills to `1/isolated` and produces @@ -619,11 +629,13 @@ building sits on a tile and nothing else. Tied layouts go through it too. **`distribution.ts` is a port of Unity's `FlowNetwork.cs`.** Two shapes in it are load-bearing and look like details: -- A supplier expands **every** unvisited adjacent consumer instead of - shortcutting to the sink when it finds one with room. The shortcut marks later - consumers visited and can pick a different equal-length augmenting path — same - maximum flow, different split, and the split decides which buildings clear - their cooling. +- The sink is reached by the **first** consumer with room to be enqueued, + through the supplier that enqueued it — the queue is FIFO, so that is the one + Unity's consumer node connects from, and the search ends at that enqueue. A + supplier must never pick the sink on its own terms (scan its edge list for a + consumer with room, or mark later consumers visited without enqueuing them): + that can pick a different equal-length augmenting path — same maximum flow, + different split, and the split decides which buildings clear their cooling. - Supply and demand are counted **down** rather than accumulated up, because `cap - sent` is not bit-identical to a decremented remainder and the golden fixtures assert exactly. @@ -738,6 +750,16 @@ it warns if the first three differ. Expect about ±0.8% per map at 15s × 5 runs so a gain smaller than that needs more runs to show; "no island got worse" is the claim it can make at any size. +**For a change to what a stage costs, use the profiler before and after.** +`npm run profile` times each stage of the search on the largest shipped island +under each anomaly — the bound, the composition targets, seed construction, +one simulation, the collector's tie check, one full repair — on the same +deterministic seed layout both times, so two runs taken back to back on an +idle machine measure the same work. It is how the seed was found to cost some 40x +what its comment claimed, and how a repair was found to cost more than the +polish share of a Quick attempt on a phone: the fixtures cannot see either, +since neither changes a number. + The survey solves at full unlocks unless told otherwise. `--unlock ID=LEVEL` (0-based, `-1` to lock) surveys another roster, and a rule that rates one role needs one: at full unlocks Singularity's generator bonus buys a capacity no diff --git a/packages/solver/package.json b/packages/solver/package.json index 1242ebc..d94c09a 100644 --- a/packages/solver/package.json +++ b/packages/solver/package.json @@ -38,6 +38,7 @@ "solve": "tsx bin/solve.ts", "fixtures": "tsx scripts/exportFixtures.ts", "survey": "tsx scripts/gapSurvey.ts", + "profile": "tsx scripts/profile.ts", "prepublishOnly": "npm run build" } } diff --git a/packages/solver/scripts/profile.ts b/packages/solver/scripts/profile.ts new file mode 100644 index 0000000..61e7de9 --- /dev/null +++ b/packages/solver/scripts/profile.ts @@ -0,0 +1,273 @@ +/** + * Measures the search's hot path on the largest shipped island, stage by + * stage. + * + * npm run profile (from the repo root) + * npm run profile -- --anomaly cryo_nexus --anomaly none + * npm run profile -- --map 3 + * + * `npm run survey` says what a real run finds; this says what it costs. One + * figure per stage — the bound, the composition targets, seed construction, + * one simulation, the collector's tie check, one full repair, and how many + * annealing steps a second buys — so a change to any of them can be judged + * against the figure it started from rather than against a feeling. + * + * Every figure is wall-clock on this machine under this load, so compare two + * runs taken back to back with nothing else running, never two taken on + * different days. The stages are taken in solve order on the same seed layout, + * and the seed is deterministic (`constructMultiStartSeed` draws no fresh + * randomness), so two runs of the same code measure the same work. + * + * It runs under `tsx` with esbuild's `keepNames` wrapper on every function, + * which the Vite bundle the app ships does not carry; the absolute figures are + * therefore a little pessimistic for the browser, and the ratios between + * stages are what to read. + */ + +import { ANOMALIES, getAnomaly } from "../src/data/anomalies"; +import { BUILDINGS, allUpgradesUnlocked } from "../src/data/buildings"; +import { getEffectiveBuildings } from "../src/data/effectiveBuildings"; +import { decodeBlueprint } from "../src/encoding/blueprint"; +import { MAPS, MAPS_BY_NUM, type CliMap } from "../bin/maps"; +import { AlternateCollector } from "../src/solver/alternates"; +import { buildIslandContext } from "../src/solver/context"; +import { + canCoolDirectProducer, + estimateTotalMaxPower, + splitGridIntoIslands, +} from "../src/solver/island"; +import { Pacer } from "../src/solver/pacer"; +import { internals } from "../src/solver/placementSearch"; +import { Rng } from "../src/solver/rng"; +import { simulateIsland } from "../src/solver/simulate"; +import type { + AnomalyId, + EffectiveBuilding, + IslandSubGrid, + Placement, +} from "../src/solver/types"; +import { formatNumber } from "../src/utils/formatters"; + +const DEFAULT_ANOMALIES: AnomalyId[] = [ + "none", + "cryo_nexus", + "tidal_ascendancy", + "singularity_isolation", +]; +/** How long the annealing walk is run to read its step rate. */ +const WALK_MS = 2000; + +const USAGE = `usage: profile [--map NUM]... [--anomaly ID]... + + --map NUM map to profile (repeatable; default: the largest island of + every map, which is one island per anomaly) + --anomaly ID anomaly to profile (repeatable; default: ${DEFAULT_ANOMALIES.join(", ")}) +`; + +interface Args { + maps: CliMap[]; + anomalies: AnomalyId[]; +} + +function parseArgs(argv: string[]): Args | null { + const maps: CliMap[] = []; + const anomalies: AnomalyId[] = []; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag === "--help" || flag === "-h") return null; + const raw = argv[++i]; + if (raw === undefined) return null; + if (flag === "--map") { + const map = MAPS_BY_NUM.get(Number(raw)); + if (map === undefined) { + console.error(`unknown map ${raw}`); + return null; + } + maps.push(map); + } else if (flag === "--anomaly") { + if (!ANOMALIES.some((a) => a.id === raw)) { + console.error(`unknown anomaly ${raw}`); + return null; + } + anomalies.push(raw as AnomalyId); + } else { + return null; + } + } + return { + maps: maps.length > 0 ? maps : MAPS.filter((m) => m.num !== 0), + anomalies: anomalies.length > 0 ? anomalies : DEFAULT_ANOMALIES, + }; +} + +const roster = getEffectiveBuildings(BUILDINGS, allUpgradesUnlocked()); +const byRole = ( + type: EffectiveBuilding["type"], + key: "effectiveValue" | "energy", +): EffectiveBuilding[] => + roster.filter((b) => b.type === type).sort((a, b) => b[key] - a[key]); +const reactors = byRole("reactor", "effectiveValue"); +const generators = byRole("generator", "effectiveValue"); +const coolers = byRole("cooler", "effectiveValue"); +const directProducers = byRole("direct_producer", "energy"); + +const ms = (from: number): string => + `${(performance.now() - from).toFixed(1)} ms`; + +/** A shortlist of ten layouts, each a few tiles from `seed`, to tie against. */ +function filledCollector(seed: Placement, n: number): AlternateCollector { + const collector = new AlternateCollector(); + const rng = new Rng(1); + for (let i = 0; i < 10; i++) { + const copy = seed.slice(); + let cleared = 0; + while (cleared < 6) { + const t = rng.int(n); + if (copy[t] !== null) { + copy[t] = null; + cleared++; + } + } + collector.offer(1, copy); + } + return collector; +} + +async function largestIsland( + maps: CliMap[], + anomalyId: AnomalyId, +): Promise<{ map: CliMap; island: IslandSubGrid } | null> { + const anomaly = getAnomaly(anomalyId); + let best: { map: CliMap; island: IslandSubGrid } | null = null; + for (const map of maps) { + const { grid } = await decodeBlueprint(map.code); + for (const island of splitGridIntoIslands( + grid, + canCoolDirectProducer(roster), + anomaly, + )) { + if (best === null || island.tileCount > best.island.tileCount) + best = { map, island }; + } + } + return best; +} + +async function profile( + map: CliMap, + island: IslandSubGrid, + anomalyId: AnomalyId, +) { + const anomaly = getAnomaly(anomalyId); + const ctx = buildIslandContext( + island.grid, + island.buildable, + anomaly, + island.waterAdjacent, + ); + console.log(`\n== ${anomaly.name}: ${map.name}, ${ctx.n} tiles ==`); + + let t0 = performance.now(); + const bound = estimateTotalMaxPower([island], roster, anomaly); + console.log( + `bound ${ms(t0).padStart(10)} (${formatNumber(bound)})`, + ); + + t0 = performance.now(); + const targets = internals.targetCompositions( + ctx.n, + reactors, + generators, + coolers, + internals.generatorCapacityTable(island, ctx, generators[0]), + ); + console.log( + `targetCompositions ${ms(t0).padStart(10)} (${targets.length} kept)`, + ); + + t0 = performance.now(); + const seed = internals.constructMultiStartSeed( + ctx, + reactors, + generators, + coolers, + directProducers, + performance.now() + 1e9, + ); + console.log(`seed ${ms(t0).padStart(10)}`); + + const sims = 20000; + t0 = performance.now(); + for (let i = 0; i < sims; i++) simulateIsland(seed, ctx); + const simUs = ((performance.now() - t0) * 1000) / sims; + console.log( + `simulateIsland ${`${simUs.toFixed(1)} us`.padStart(10)} (${Math.round(1e6 / simUs / 1000)}k/s)`, + ); + + const collector = filledCollector(seed, ctx.n); + const ties = 20000; + t0 = performance.now(); + for (let i = 0; i < ties; i++) collector.accepts(seed); + const tieUs = ((performance.now() - t0) * 1000) / ties; + console.log( + `collector.accepts ${`${tieUs.toFixed(1)} us`.padStart(10)} (${Math.round((tieUs / simUs) * 100)}% of a simulation, on a tie)`, + ); + + const candidates: (EffectiveBuilding | null)[] = [ + ...reactors.slice(0, 3), + generators[0], + coolers[0], + ...directProducers.slice(0, 1), + null, + ]; + t0 = performance.now(); + const polished = await internals.greedyPolish( + seed, + ctx, + candidates, + performance.now() + 1e9, + new Pacer(), + ); + console.log( + `greedyPolish on seed ${ms(t0).padStart(10)} (${formatNumber(simulateIsland(seed, ctx).totalPower)} -> ${formatNumber(polished.power)})`, + ); + + let steps = 0; + const pacer = new Pacer({ shouldStop: () => (steps++, false) }); + t0 = performance.now(); + await internals.hillClimb( + polished.placement, + ctx, + reactors, + generators, + coolers, + directProducers, + WALK_MS, + new Rng(7), + pacer, + undefined, + new AlternateCollector(), + ); + // The walk yields every 50ms and asks `shouldStop` each time, so this is + // its yield count; the step rate is read off the simulation cost instead, + // since every step is one simulation plus the tie check. + console.log( + `hillClimb ${ms(t0).padStart(10)} (${steps} yields; about ${Math.round(1e6 / (simUs + tieUs) / 1000)}k steps/s at most)`, + ); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args === null) { + console.log(USAGE); + process.exitCode = 1; + return; + } + for (const anomalyId of args.anomalies) { + const found = await largestIsland(args.maps, anomalyId); + if (found === null) continue; + await profile(found.map, found.island, anomalyId); + } +} + +await main(); diff --git a/packages/solver/src/solver/alternates.ts b/packages/solver/src/solver/alternates.ts index 0102826..57de29c 100755 --- a/packages/solver/src/solver/alternates.ts +++ b/packages/solver/src/solver/alternates.ts @@ -189,7 +189,12 @@ export class AlternateCollector { * converged walk mostly produces, and it only ever reads the layout's tiles. */ accepts(placement: Placement): boolean { - if (this.#layouts.has(placementKey(placement))) return false; + // An identical board is at distance 0 and fails the loop below, so the key + // lookup is only needed when the distance rule is off. It is an O(n) + // string build, asked on every tied step of a converged walk, where it was + // a fifth of the cost of the simulation that produced the tie. + if (this.#minDistance <= 0 && this.#layouts.has(placementKey(placement))) + return false; for (const held of this.#layouts.values()) { if ( placementDistance(placement, held, this.#minDistance) < diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index b2fc8d3..59f179a 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -41,7 +41,6 @@ export class DistributionScratch { * exactly. */ readonly remSupplierCap: Float64Array; readonly remConsumerCap: Float64Array; - readonly eligible: Int32Array; /** CSR-style adjacency: items[start[i] .. start[i + 1]) */ readonly supAdjStart: Int32Array; @@ -52,6 +51,11 @@ export class DistributionScratch { readonly visitedToken: Int32Array; readonly parentNode: Int32Array; readonly queue: Int32Array; + /** + * Augmenting Repair's suppliers with supply left, for the component being + * repaired, in ascending index order. Compacted as suppliers run dry. + */ + readonly liveSuppliers: Int32Array; /** * Per-supplier FairShare round budget: the number of suppliers sharing that @@ -80,7 +84,6 @@ export class DistributionScratch { this.consumerReceived = new Float64Array(n); this.remSupplierCap = new Float64Array(n); this.remConsumerCap = new Float64Array(n); - this.eligible = new Int32Array(n); this.supAdjStart = new Int32Array(n + 1); this.supAdjItems = new Int32Array(maxAdj); this.conAdjStart = new Int32Array(n + 1); @@ -88,6 +91,7 @@ export class DistributionScratch { this.visitedToken = new Int32Array(totalNodes); this.parentNode = new Int32Array(totalNodes); this.queue = new Int32Array(totalNodes); + this.liveSuppliers = new Int32Array(n); this.supRoundBudget = new Int32Array(n); this.componentOf = new Int32Array(n); this.componentMembers = new Int32Array(n); diff --git a/packages/solver/src/solver/distribution.ts b/packages/solver/src/solver/distribution.ts index 2c2ad11..c547a44 100755 --- a/packages/solver/src/solver/distribution.ts +++ b/packages/solver/src/solver/distribution.ts @@ -18,11 +18,17 @@ import type { IslandContext } from "./context"; * * Two shapes here are load-bearing and neither is arbitrary: * - * - A supplier does **not** jump straight to the sink on finding a consumer - * with room. It enqueues the consumer and lets the consumer node connect, - * because the shortcut marks later consumers visited and can select a - * different equal-length augmenting path — same maximum flow, different - * split, and the split is what decides which buildings clear their cooling. + * - The sink is reached by the consumer with room that was enqueued + * **first**, with the supplier that enqueued it as its parent — the queue is + * FIFO, so that is the one Unity's consumer node connects from, and every + * node before it on the path was already parented when it was enqueued. + * The search here ends at that enqueue rather than at the dequeue, which + * skips the expansions in between and nothing else. What it must never do + * is let a supplier pick the sink on its own terms — scanning its edge list + * for a consumer with room, or marking later consumers visited without + * enqueuing them — because that can select a different equal-length + * augmenting path: same maximum flow, different split, and the split is + * what decides which buildings clear their cooling. * - Supply and demand are counted DOWN. `cap - sent` accumulated separately is * not bit-identical to a decremented remainder, and the parity fixtures are * asserted exactly. @@ -256,6 +262,7 @@ export function runDistribution( const visitedToken = d.visitedToken; const parentNode = d.parentNode; const queue = d.queue; + const live = d.liveSuppliers; visitedToken.fill(0, 0, sink + 1); let token = 0; @@ -279,59 +286,75 @@ export function runDistribution( const memberFrom = componentStart[comp]; const memberTo = componentStart[comp + 1]; + // The suppliers the source would enqueue, kept in ascending index order + // and compacted as they run dry. Supply only ever goes down, so a supplier + // dropped here is never enqueued again — the same set, in the same order, + // as a scan of the whole component, read from the few that are left rather + // than from every member on every search. Under a Cryo Nexus board the + // heat graph repairs with some sixty searches over a component of thirty + // suppliers, eight of which still hold anything. + let liveCount = 0; + for (let m = memberFrom; m < memberTo; m++) { + const sIdx = componentMembers[m]; + if (supRem[sIdx] > EPS) live[liveCount++] = sIdx; + } + for (;;) { - let anyLeft = false; - for (let m = memberFrom; m < memberTo; m++) { - if (supRem[componentMembers[m]] > EPS) { - anyLeft = true; - break; + token += 1; + visitedToken[source] = token; + + // Source -> every supplier with remaining supply: the source's own + // expansion, done in place rather than through the queue. + let qTail = 0; + let keep = 0; + for (let m = 0; m < liveCount; m++) { + const sIdx = live[m]; + if (supRem[sIdx] > EPS) { + live[keep++] = sIdx; + visitedToken[sIdx] = token; + parentNode[sIdx] = source; + queue[qTail++] = sIdx; } } - if (!anyLeft) break; + liveCount = keep; + if (liveCount === 0) break; - token += 1; - visitedToken[source] = token; - queue[0] = source; let qHead = 0; - let qTail = 1; let reachedSink = false; while (qHead < qTail && !reachedSink) { const node = queue[qHead++]; - if (node === source) { - for (let m = memberFrom; m < memberTo; m++) { - const sIdx = componentMembers[m]; - if (visitedToken[sIdx] === token) continue; - if (supRem[sIdx] > EPS) { - visitedToken[sIdx] = token; - parentNode[sIdx] = source; - queue[qTail++] = sIdx; - } - } - } else if (node < numSuppliers) { - // Supplier -> every unvisited adjacent consumer. Forward residual is - // unbounded, and the sink is deliberately NOT reached from here. + if (node < numSuppliers) { + // Supplier -> every unvisited adjacent consumer, in edge order. + // Forward residual is unbounded. The first consumer with room to be + // enqueued is the one that would connect to the sink when dequeued + // (FIFO), with this supplier as its parent, so the search is over + // the moment it is enqueued; the file header says why it must be + // the first *enqueued* and not any other consumer with room. for (let k = supAdjStart[node]; k < supAdjStart[node + 1]; k++) { - const conNode = numSuppliers + supAdjItems[k]; + const cIdx = supAdjItems[k]; + const conNode = numSuppliers + cIdx; if (visitedToken[conNode] === token) continue; visitedToken[conNode] = token; parentNode[conNode] = node; queue[qTail++] = conNode; + if (demRem[cIdx] > EPS) { + parentNode[sink] = conNode; + reachedSink = true; + break; + } } - } else if (node < source) { + } else { + // A consumer without room (one with room ended the search above): + // walk back-edges to the suppliers already sending to it. The + // visited test comes first because it is the cheaper load, and + // either test skipping the edge is the same skip. const cIdx = node - numSuppliers; - if (visitedToken[sink] !== token && demRem[cIdx] > EPS) { - visitedToken[sink] = token; - parentNode[sink] = node; - reachedSink = true; - break; - } - for (let m = conAdjStart[cIdx]; m < conAdjStart[cIdx + 1]; m++) { const sIdx = conAdjItems[m]; - if (flow[sIdx * numConsumers + cIdx] <= EPS) continue; if (visitedToken[sIdx] === token) continue; + if (flow[sIdx * numConsumers + cIdx] <= EPS) continue; visitedToken[sIdx] = token; parentNode[sIdx] = node; queue[qTail++] = sIdx; diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index f8df05d..1506a63 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -1,4 +1,4 @@ -import { EPS } from "./constants"; +import { CHEBYSHEV_DIRECTIONS, EPS } from "./constants"; import { generatorEnergyRatio, generatorPowerAndWaste, @@ -91,8 +91,21 @@ const POOL_COOLER_VALUE = Infinity; /** How many reactor tiers the local-repair pass considers per tile. */ const POLISH_REACTOR_TIERS = 3; +/** + * How far from an applied repair move the cached gains are re-measured. A + * change on a tile is felt through its neighbours' supply and demand, so a + * tile two steps away shares a neighbour with it and one three steps away + * shares nothing — see `greedyPolish`. + */ +const POLISH_DIRTY_RADIUS = 2; /** Share of the time budget reserved for repair before and after annealing. */ const POLISH_SHARE = 0.3; +/** + * The least time the plain-sized seed's repair is given under a role + * isolation, when the sized seed's own repair took less (see `solveIsland`). + * A 20-tile island repairs in 3ms, and a cap that tight would be timer noise. + */ +const PLAIN_SEED_POLISH_MIN_MS = 20; /** How many candidate compositions to try arranging, best-scoring first. */ const COMPOSITION_TARGETS = 3; /** Share of the time budget spent arranging those compositions. */ @@ -640,6 +653,82 @@ function bestSharedReactorFit( return best; } +/** A `SharedFit` with every tile spelled as a direction from its centre. */ +interface RelativeSharedFit { + power: number; + waste: number; + /** Direction index per reactor, or -1 for the centre tile itself. */ + reactorDirections: number[]; + reactorTiers: EffectiveBuilding[]; + genDirections: number[]; + coolerDirections: number[]; +} + +/** + * The shape of a tile's free neighbourhood: a bit per `CHEBYSHEV_DIRECTIONS` + * entry that holds a free tile, with the maps between tile and direction. + */ +function neighbourhoodShape( + ctx: IslandContext, + tile: number, + neighbors: number[], +): { + mask: number; + directionOf: Map; + tileAt: Int32Array; +} { + let mask = 0; + const directionOf = new Map(); + const tileAt = new Int32Array(CHEBYSHEV_DIRECTIONS.length).fill(-1); + for (const n of neighbors) { + const dx = ctx.xs[n] - ctx.xs[tile]; + const dy = ctx.ys[n] - ctx.ys[tile]; + for (let d = 0; d < CHEBYSHEV_DIRECTIONS.length; d++) { + if ( + CHEBYSHEV_DIRECTIONS[d][0] === dx && + CHEBYSHEV_DIRECTIONS[d][1] === dy + ) { + mask |= 1 << d; + directionOf.set(n, d); + tileAt[d] = n; + break; + } + } + } + return { mask, directionOf, tileAt }; +} + +function toRelativeFit( + fit: SharedFit, + directionOf: Map, +): RelativeSharedFit { + const direction = (t: number): number => directionOf.get(t) ?? -1; + return { + power: fit.power, + waste: fit.waste, + reactorDirections: fit.reactorTiles.map(direction), + reactorTiers: fit.reactorTiers, + genDirections: fit.genPositions.map(direction), + coolerDirections: fit.coolerPositions.map(direction), + }; +} + +function fromRelativeFit( + fit: RelativeSharedFit, + tile: number, + tileAt: Int32Array, +): SharedFit { + const at = (d: number): number => (d < 0 ? tile : tileAt[d]); + return { + power: fit.power, + waste: fit.waste, + reactorTiles: fit.reactorDirections.map(at), + reactorTiers: fit.reactorTiers, + genPositions: fit.genDirections.map(at), + coolerPositions: fit.coolerDirections.map(at), + }; +} + interface CandidateEntry { /** * Power PER TILE CLAIMED, which is what candidates are ranked by — not raw @@ -669,6 +758,7 @@ function constructSeed( directProducers: EffectiveBuilding[], deadlineMs: number, pooled = false, + sizeByIsolation = true, ): Placement { const placement = emptyPlacement(ctx.n); const available = new Uint8Array(ctx.n); @@ -678,12 +768,58 @@ function constructSeed( const topReactor = reactors.length > 0 ? reactors[0] : null; const topGenerator = generators.length > 0 ? generators[0] : null; const topCooler = coolers.length > 0 ? coolers[0] : null; + + // Under a generator isolation a hub is sized with its generator at the + // ISOLATED rating, and no hub's generator is put beside another. Sized from + // the plain roster, a full-unlock hub is one ecto reactor filling one + // generator and the "shared" fit stands three generators round one reactor + // — every one of them crowded, on a board where an isolated generator takes + // two reactors and makes four times the power. That seed scored 32% of the + // Singularity bound on Event Horizon where the base seed scores 60% of its + // own (`npm run profile`), and the first repair had to double it. The fit + // is sized from the rated figure and the plain entry is what is written, so + // `simulateIsland` rates the tile from its neighbours as it always does. + // Off under every other rule, so the fixtures reproduce byte for byte. + // + // Measured with the survey (`--anomaly singularity_isolation --seed 1`): + // after the first repair the seed reads 78-88% of the bound on the eight + // largest islands against the plain one's 50-64%. At 15s x 5 runs that is + // a wash, -1.5% to +2.2% per map and every map inside its band; at 5s x 3 + // runs, where the budget binds, Event Horizon gains 14% and Shadowspire + // 18%, four maps 2-4%, Ash Bay and Magma Rift read 4% and 1% lower inside + // their bands. With generator7 at tier 3 — the roster where the bonus is + // worth most — every map is inside 0.8%. + const isolation = ctx.isolation; + const isolatedSeed = + sizeByIsolation && + isolation !== null && + isolation.role === "generator" && + topGenerator !== null && + ctx.rateIsolated(topGenerator, true).effectiveValue < + ctx.rateIsolated(topGenerator, false).effectiveValue; + const fitGenerators = isolatedSeed + ? generators.map((g) => ctx.rateIsolated(g, false)) + : generators; + const fitGenerator = fitGenerators.length > 0 ? fitGenerators[0] : null; + const plainOf = new Map(); + for (let i = 0; i < generators.length; i++) + plainOf.set(fitGenerators[i], generators[i]); + /** Whether a generator written on `tile` would crowd, or be crowded by, one. */ + const besideGenerator = (tile: number): boolean => { + const nb = ctx.neighbors[tile]; + for (let k = 0; k < nb.length; k++) { + const b = placement[nb[k]]; + if (b !== null && b.type === "generator") return true; + } + return false; + }; + const canHub = topReactor !== null && topGenerator !== null && topCooler !== null; const canDp = directProducers.length > 0 && topCooler !== null; const trySharedReactor = canHub && - Math.ceil(topReactor!.effectiveValue / topGenerator!.effectiveValue) >= 2; + Math.ceil(topReactor!.effectiveValue / fitGenerator!.effectiveValue) >= 2; // Under a cooling pool a hub is sized with a phantom cooler that covers any // waste from nowhere, so every neighbour slot goes to reactors and no cooler @@ -723,10 +859,59 @@ function constructSeed( const dpFitCache = new Map(); const sharedScratch = emptyPlacement(ctx.n); + // A shared fit is simulated on an otherwise empty board, so it is a function + // of the neighbourhood's *shape* alone — which of the eight directions hold a + // free tile — as long as every tile rates a building the same: the tile + // order the distribution runs in is the spatial order, which the same shape + // reproduces wherever it sits. That is true under a uniform rating and under + // a role scale, which rates every tile alike; a terrain bonus is per tile and + // keeps the uncached path. Memoised by that shape, because this is the whole + // cost of the seed: it used to re-simulate every generator combination for + // every stale tile on every round, 0.5-0.8s on the largest shipped islands. + const sharedFitByShape = + ctx.uniformRating || ctx.anomaly.rule === "shared_cooling" + ? new Map() + : null; + const sharedFit = (tile: number, neighbors: number[]): SharedFit | null => { + if (sharedFitByShape === null) { + return bestSharedReactorFit( + tile, + neighbors, + reactors, + fitGenerator!, + fitCooler!, + ctx, + sharedScratch, + pooled, + ); + } + const shape = neighbourhoodShape(ctx, tile, neighbors); + let relative = sharedFitByShape.get(shape.mask); + if (relative === undefined) { + const fit = bestSharedReactorFit( + tile, + neighbors, + reactors, + fitGenerator!, + fitCooler!, + ctx, + sharedScratch, + pooled, + ); + relative = fit === null ? null : toRelativeFit(fit, shape.directionOf); + sharedFitByShape.set(shape.mask, relative); + } + return relative === null + ? null + : fromRelativeFit(relative, tile, shape.tileAt); + }; + const hubFit = (slots: number): HubFit | null => { let fit = hubFitCache.get(slots); if (fit === undefined) { - fit = canHub ? bestHubFit(slots, generators, reactors, fitCooler!) : null; + fit = canHub + ? bestHubFit(slots, fitGenerators, reactors, fitCooler!) + : null; hubFitCache.set(slots, fit); } return fit; @@ -758,10 +943,15 @@ function constructSeed( if (entry === null || candidate.powerPerTile > entry.powerPerTile) entry = candidate; }; + // A hub's generator stands on `tile`; beside a placed generator it + // would be crowded, and crowd that one. Only the stale tiles are + // re-read here and a claim marks every tile beside it stale, so this + // sees each generator as it lands. + const crowds = isolatedSeed && besideGenerator(tile); // Under a pool the fit shrinks until its reactors and its coolers both // fit in what is left; under the base rules the first fit is the one. - let hub = hubFit(neighbors.length); + let hub = crowds ? null : hubFit(neighbors.length); while (hub !== null && !reserves(1 + hub.r, hub.waste)) { hub = hub.r > 1 ? hubFit(hub.r - 1) : null; } @@ -793,17 +983,11 @@ function constructSeed( } if (trySharedReactor && neighbors.length >= 3) { - const shared = bestSharedReactorFit( - tile, - neighbors, - reactors, - topGenerator!, - fitCooler!, - ctx, - sharedScratch, - pooled, - ); - if (shared !== null) { + const shared = sharedFit(tile, neighbors); + if ( + shared !== null && + !(isolatedSeed && shared.genPositions.some(besideGenerator)) + ) { const tilesUsed = shared.reactorTiles.length + shared.genPositions.length + @@ -865,7 +1049,7 @@ function constructSeed( const claimed: number[] = []; if (best.kind === "hub") { const { generator, reactor, r, c } = best.hub!; - put(placement, ctx, best.tile, generator); + put(placement, ctx, best.tile, plainOf.get(generator) ?? generator); claimed.push(best.tile); for (let i = 0; i < r; i++) { put(placement, ctx, best.neighbors[i], reactor); @@ -964,6 +1148,11 @@ function constructSeed( return placement; } +/** + * `sizeByIsolation` is `constructSeed`'s: false builds the plain-sized seed + * under a role isolation, which `solveIsland` compares against the sized one. + * It means nothing under any other rule. + */ function constructMultiStartSeed( ctx: IslandContext, reactors: EffectiveBuilding[], @@ -971,10 +1160,14 @@ function constructMultiStartSeed( coolers: EffectiveBuilding[], directProducers: EffectiveBuilding[], deadlineMs: number, + sizeByIsolation = true, ): Placement { const buildable = Array.from(ctx.tiles); - const passes = (pooled: boolean): { seed: Placement; power: number } => { + const passes = ( + pooled: boolean, + sizeByIsolation = true, + ): { seed: Placement; power: number } => { let bestSeed = constructSeed( buildable, ctx, @@ -984,14 +1177,16 @@ function constructMultiStartSeed( directProducers, deadlineMs, pooled, + sizeByIsolation, ); let bestPower = simulateIsland(bestSeed, ctx).totalPower; if (bestPower <= EPS) { // The deadline elapsed mid-construction and left a seed that powers - // nothing. One full greedy pass costs ~2ms even on the largest island in - // the game, so finish it: refining a degenerate seed wastes the whole - // budget and can return an empty layout for a solvable island. + // nothing. One full greedy pass costs about 25ms on the largest island + // in the game (`npm run profile`), so finish it: refining a degenerate + // seed wastes the whole budget and can return an empty layout for a + // solvable island. bestSeed = constructSeed( buildable, ctx, @@ -1001,6 +1196,7 @@ function constructMultiStartSeed( directProducers, performance.now() + 1000, pooled, + sizeByIsolation, ); bestPower = simulateIsland(bestSeed, ctx).totalPower; } @@ -1018,6 +1214,7 @@ function constructMultiStartSeed( directProducers, deadlineMs, pooled, + sizeByIsolation, ); const powerRev = simulateIsland(seedRev, ctx).totalPower; if (powerRev > bestPower + EPS) { @@ -1038,6 +1235,7 @@ function constructMultiStartSeed( directProducers, deadlineMs, pooled, + sizeByIsolation, ); const powerRand = simulateIsland(seedRand, ctx).totalPower; if (powerRand > bestPower + EPS) { @@ -1049,7 +1247,7 @@ function constructMultiStartSeed( return { seed: bestSeed, power: bestPower }; }; - const local = passes(false); + const local = passes(false, sizeByIsolation); if (ctx.anomaly.rule !== "shared_cooling") return local.seed; // Under a pool the same three orders are run engine-first as well, and the @@ -1175,7 +1373,6 @@ async function hillClimb( const pools = [reactors, generators, coolers, directProducers].filter( (p) => p.length > 0, ); - const startMs = performance.now(); const deadlineMs = startMs + timeBudgetMs; @@ -1414,7 +1611,21 @@ function targetCompositions( const generator = generators[0]; const cooler = coolers[0]; + // Only the `topK` best are kept as they are found, and a composition's + // array is built only once it has earned a place. Materialising all + // O(tiles^2) of them and sorting afterwards cost 80-130ms on the largest + // shipped islands, nearly all of it allocation. Insertion goes after every + // entry of equal power, which is exactly what the stable descending sort it + // replaces produced: equal scores in enumeration order. const scored: ScoredComposition[] = []; + const earnsPlace = (power: number): boolean => + scored.length < topK || power > scored[scored.length - 1].power; + const keep = (entry: ScoredComposition): void => { + let at = scored.length; + while (at > 0 && scored[at - 1].power < entry.power) at--; + scored.splice(at, 0, entry); + if (scored.length > topK) scored.pop(); + }; for (let nGen = 1; nGen < tileCount - 1; nGen++) { for (let nCool = 1; nCool < tileCount - nGen; nCool++) { @@ -1467,20 +1678,17 @@ function targetCompositions( for (const b of fill) fillHeat += b.effectiveValue; const absorbed = Math.min(fillHeat, budget); const power = absorbed * generatorEnergyRatio(generator); - if (power > EPS) { + if (power > EPS && earnsPlace(power)) { const composition = fill.slice(); for (let i = 0; i < nGen; i++) composition.push(generator); for (let i = 0; i < nCool; i++) composition.push(cooler); - scored.push({ power, composition }); + keep({ power, composition }); } } } } - // Stable sort, so equal-scoring compositions keep the order they were - // enumerated in and the walk is reproducible. - scored.sort((a, b) => b.power - a.power); - return scored.slice(0, topK); + return scored; } /** @@ -1867,44 +2075,103 @@ async function greedyPolish( bestPower = stabilized.power; } - while (performance.now() < deadlineMs && !pacer.stopRequested) { - let bestGain = 0.0; - let bestTile = -1; - let bestBuilding: EffectiveBuilding | null = null; + // The gain of every (tile, candidate) change, measured against the layout + // as it stood when that tile was last swept. A full sweep after every + // accepted move was the whole cost of this stage — |tiles| x |candidates| + // simulations per move, 2-3s for one repair on the largest shipped islands + // — and nearly all of it re-measured changes far from the move, whose gain + // the move cannot have touched: a single-tile change is felt through its + // neighbours' supply and demand, so only tiles within `POLISH_DIRTY_RADIUS` + // of an applied move are swept again. The rest keep their figure, which is + // approximate once anything nearby has moved, so a cached winner is always + // re-simulated before it is applied and dropped back into the sweep if it + // no longer holds. Every applied change is therefore verified exactly; + // only the order the changes are found in can differ from a full sweep. + // + // Measured with the survey (`--runs 3 --seed 1`, every map and anomaly): + // at 15s per map it is a wash, every map inside the noise band; at 5s — + // where the polish share binds, as it does on a phone at any budget — Tidal + // gains 5-9% on six of the eight maps and Shadowspire under Singularity + // 16%, against one 83-tile island down 0.7%. One repair on the seed of the + // largest island went from 950ms to 180ms (`npm run profile`). + const nCandidates = candidates.length; + const gains = new Float64Array(ctx.n * nCandidates).fill(-Infinity); + const dirty = new Uint8Array(ctx.n).fill(1); + while (performance.now() < deadlineMs && !pacer.stopRequested) { + let cut = false; for (let tile = 0; tile < ctx.n; tile++) { + if (dirty[tile] === 0) continue; const now = performance.now(); - if (now >= deadlineMs) break; + if (now >= deadlineMs) { + cut = true; + break; + } if (pacer.due(now)) { await pacer.pump(now, () => islandProgress(current, ctx)); - if (pacer.stopRequested) break; + if (pacer.stopRequested) { + cut = true; + break; + } } + dirty[tile] = 0; const held = current[tile]; - for (const building of candidates) { + for (let k = 0; k < nCandidates; k++) { + const building = candidates[k]; + let gain = -Infinity; // The candidate list is the plain roster, `held` is rated for this tile: // asked the wrong way round, a scaled tile re-simulates its own layout // once per sweep for nothing. See `ratedFor`. - if (ratedFor(ctx, tile, building) === held) continue; - - put(current, ctx, tile, building); - const trial = simulateIsland(current, ctx); - if ( - trial.totalPower - bestPower > bestGain && - !anyOfflineProducer(current, trial.placements) && - planHolds(current, ctx) - ) { - bestGain = trial.totalPower - bestPower; - bestTile = tile; - bestBuilding = building; + if (ratedFor(ctx, tile, building) !== held) { + put(current, ctx, tile, building); + const trial = simulateIsland(current, ctx); + if ( + trial.totalPower - bestPower > 0 && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + gain = trial.totalPower - bestPower; + } + put(current, ctx, tile, held); } - put(current, ctx, tile, held); + gains[tile * nCandidates + k] = gain; } } + if (cut) break; + let bestGain = 0.0; + let bestTile = -1; + let bestCandidate = -1; + for (let i = 0; i < gains.length; i++) { + if (gains[i] > bestGain) { + bestGain = gains[i]; + bestTile = (i / nCandidates) | 0; + bestCandidate = i - bestTile * nCandidates; + } + } if (bestTile < 0) break; - put(current, ctx, bestTile, bestBuilding); - bestPower += bestGain; + + // Exact, whether the figure was fresh or cached: the one simulation that + // decides what this stage hands on. + const held = current[bestTile]; + put(current, ctx, bestTile, candidates[bestCandidate]); + const trial = simulateIsland(current, ctx); + if ( + trial.totalPower - bestPower > 0 && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + bestPower = trial.totalPower; + for (let tile = 0; tile < ctx.n; tile++) { + if (chebyshev(ctx, tile, bestTile) <= POLISH_DIRTY_RADIUS) + dirty[tile] = 1; + } + } else { + // Stale: the layout moved under it. Back to the sweep for a fresh figure. + put(current, ctx, bestTile, held); + dirty[bestTile] = 1; + } } return { placement: current, power: bestPower }; @@ -2589,13 +2856,14 @@ function gatherPooledCoolers( * onto the same handful of tiles. This is the gate that actually holds — it is * the last place the layouts change shape. */ -function finalizeAlternates( +async function finalizeAlternates( collector: AlternateCollector, primaryRows: PlacedBuilding[], primaryPower: number, effectiveBuildings: EffectiveBuilding[], ctx: IslandContext, -): IslandLayout[] { + pacer: Pacer, +): Promise { const kept: PlacedBuilding[][] = [primaryRows]; const out: IslandLayout[] = []; // One gathering budget for the whole shortlist rather than one each, so a @@ -2605,6 +2873,16 @@ function finalizeAlternates( performance.now() + EVACUATE_BUDGET_MS + GATHER_BUDGET_MS; for (const layout of collector.layouts()) { + // This runs after the search's deadline, and each alternate is a few + // hundred simulations — up to a second under a cooling pool — so come up + // for air between them: a STOP that arrives here ends the shortlist where + // it stands rather than waiting for ten more layouts to be finished. + const now = performance.now(); + if (pacer.due(now)) { + await pacer.pump(now); + if (pacer.stopRequested) break; + } + const pruned = pruneDeadWeight(layout, ctx); const downsized = downgradeOversized( pruned.rows, @@ -2866,12 +3144,49 @@ export async function solveIsland( Math.min(deadlineMs, polishStartedMs + polishBudgetMs), pacer, ); - let placement = polished.placement; - let bestPower = polished.power; // How long a full repair actually took here, used below to reserve just // enough for the closing pass instead of a fixed (over-generous) share. const polishElapsedMs = performance.now() - polishStartedMs; + // Under a role isolation the seed above was sized for it (`constructSeed`), + // and the plain-sized seed is built and repaired as well; the search starts + // from whichever repairs higher, the plain one on a tie. Judged after the + // repair rather than before it, because the raw figure picks wrong: on + // Entropy Isles' 20-tile island both seeds repair to five full hubs at the + // same power, and from the sized one — 20 tiles of 20 used, every + // generator already full — the later stages never reach the six-generator + // optimum the plain one leads them to (2.36AD against 2.54AD at its 0.3s + // budget). On every large shipped island the sized seed repairs to 78-88% + // of the bound against the plain one's 50-64%, so the tie rule only + // decides where the sizing did not help, and there the search starts where + // every other rule starts. The plain repair gets the time the sized one + // took, so this costs at most one more repair; cut short it only reads + // lower, and the sized seed wins as it should. + if (ctx.isolation !== null) { + const plainSeed = constructMultiStartSeed( + ctx, + reactors, + generators, + coolers, + directProducers, + deadlineMs, + false, + ); + const plainPolished = await greedyPolish( + plainSeed, + ctx, + candidates, + Math.min( + deadlineMs, + performance.now() + Math.max(polishElapsedMs, PLAIN_SEED_POLISH_MIN_MS), + ), + pacer, + ); + if (plainPolished.power + EPS >= polished.power) polished = plainPolished; + } + let placement = polished.placement; + let bestPower = polished.power; + // Composition-first attempt, BEFORE annealing so its result becomes the // walk's starting point rather than competing with it for time. What to build // is a counting problem with an exact answer, so solve that directly instead @@ -2955,6 +3270,13 @@ export async function solveIsland( RECLAIM_MIN_ROUND_MS, timeBudgetMs * RECLAIM_MIN_ROUND_SHARE, ); + // + // Each round reheats to the walk's full starting temperature. Starting the + // reclaimed rounds at a tenth of it — on the reasoning that a short round + // from a repaired layout spends most of itself wandering off a layout it + // then hands straight back — was measured with the survey at 15s over three + // runs on every map and anomaly: every map inside the noise band, so the + // one schedule is kept. for (;;) { if (annealMs > 0 && !pacer.stopRequested) { placement = await hillClimb( @@ -2998,6 +3320,14 @@ export async function solveIsland( if (annealMs < minRoundMs) break; } + // Everything from here runs past the deadline and none of it yields on its + // own, so yield once before it: the walk's last pump may be most of a yield + // interval ago, and the cleanup below is a second of work under a pool. + { + const now = performance.now(); + if (pacer.due(now)) await pacer.pump(now); + } + // Post-search cleanup pass... const pruned = pruneDeadWeight(placement, ctx); // ...and then right-size what survived. The search had no reason to prefer @@ -3022,12 +3352,13 @@ export async function solveIsland( return { placements, powerOutput: sized.power, - alternates: finalizeAlternates( + alternates: await finalizeAlternates( collector, placements, sized.power, effectiveBuildings, ctx, + pacer, ), }; } diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts index 06d6c88..99dfa4b 100755 --- a/packages/solver/src/solver/simulate.ts +++ b/packages/solver/src/solver/simulate.ts @@ -82,10 +82,16 @@ export function simulateIsland( let numCoolers = 0; let numOccupied = 0; + // A role-isolation rule re-rates a layout below; the copy it starts from is + // taken in this same pass rather than in one of its own. + const isolation = ctx.isolation; + const rated = ctx.ratedLayout; + // Ascending tile index is the game's spatial processing order, so every list // built here is already sorted the way the distribution rules require. for (let t = 0; t < n; t++) { const b = placement[t]; + if (isolation !== null) rated[t] = b; if (b === null) continue; numOccupied++; if (b.type === "generator") { @@ -122,11 +128,7 @@ export function simulateIsland( // rather than the buffer it is filling: the test is on what a neighbour *is*, // which no rating changes, so there is no order to get right. let layout = placement; - const isolation = ctx.isolation; if (isolation !== null) { - const rated = ctx.ratedLayout; - for (let t = 0; t < n; t++) rated[t] = placement[t]; - const affected = isolation.role === "generator" ? generatorTiles diff --git a/packages/solver/tests/goldenLayoutsFullTier.test.ts b/packages/solver/tests/goldenLayoutsFullTier.test.ts index ceb247a..fa264d3 100644 --- a/packages/solver/tests/goldenLayoutsFullTier.test.ts +++ b/packages/solver/tests/goldenLayoutsFullTier.test.ts @@ -216,10 +216,8 @@ describe("a three-tile row", () => { * the ecto's 2.88e23 and the rest goes nowhere; the cooler is idling at 7%. * Power is the generator's full rated 1.06e23. */ - golden( - ["XGC"], - { ecto_compressor: 1, generator7: 1, cooler8: 1 }, - () => sharedOutput(1, 1), + golden(["XGC"], { ecto_compressor: 1, generator7: 1, cooler8: 1 }, () => + sharedOutput(1, 1), ); }); @@ -231,10 +229,8 @@ describe("a four-tile square", () => { * The fourth tile goes to a second generator, not a second reactor: the * first ecto was already spilling more than a whole generator's worth. */ - golden( - ["GC", "XG"], - { ecto_compressor: 1, generator7: 2, cooler8: 1 }, - () => sharedOutput(1, 2), + golden(["GC", "XG"], { ecto_compressor: 1, generator7: 2, cooler8: 1 }, () => + sharedOutput(1, 2), ); it("doubles the three-tile row exactly", () => { @@ -253,10 +249,8 @@ describe("a five-tile island", () => { * converted, is 2.1499e23 against 2.12e23. */ const LAYOUT = ["GCG", "GX."]; - golden( - LAYOUT, - { ecto_compressor: 1, generator7: 3, cooler8: 1 }, - () => sharedOutput(1, 3), + golden(LAYOUT, { ecto_compressor: 1, generator7: 3, cooler8: 1 }, () => + sharedOutput(1, 3), ); it("beats the four-tile square by only the ecto's overflow", () => { @@ -311,10 +305,8 @@ describe("an eight-tile island", () => { * last 8e21 — 4.2997e23, beating 3 ectos + 4 generators' 4.24e23. */ const LAYOUT = ["GXG", "XCG", "GG."]; - golden( - LAYOUT, - { ecto_compressor: 2, generator7: 5, cooler8: 1 }, - () => sharedOutput(2, 5), + golden(LAYOUT, { ecto_compressor: 2, generator7: 5, cooler8: 1 }, () => + sharedOutput(2, 5), ); it("prefers a fifth generator to a third reactor", () => { From 32ce26dfba60010c7399361bc66b9d9d531f031a Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:18:15 +0300 Subject: [PATCH 5/5] Split placementSearch.ts into one file per search stage A pure move: every line of the old file lands verbatim in exactly one of search/placement, stability, seed, anneal, composition, repair, rightSize and pooledCoolers, or stays in placementSearch.ts with solveIsland, the budget split, the deterministic replay and `internals`. That module still exports exactly what it did, so no import site changes. Imports and exports were derived from what each body uses, and the new modules form no cycle. The one edit is a doc comment, "Solves one island", which sat above replayIslandDeterministic and now sits on solveIsland. The fixtures reproduce byte for byte and the stage profiler cannot tell the two trees apart. The worker-safety layering rule read `../x` as leaving src/solver/, which holds only for a module directly inside it: a subfolder importing `../constants` would have been refused. Each specifier is now resolved against the importing file's own folder, and the cases a subfolder raises are pinned. --- docs/SOLVER.md | 14 +- packages/solver/CLAUDE.md | 11 +- packages/solver/src/solver/placementSearch.ts | 2972 +---------------- packages/solver/src/solver/search/anneal.ts | 324 ++ .../solver/src/solver/search/composition.ts | 465 +++ .../solver/src/solver/search/placement.ts | 111 + .../solver/src/solver/search/pooledCoolers.ts | 405 +++ packages/solver/src/solver/search/repair.ts | 261 ++ .../solver/src/solver/search/rightSize.ts | 216 ++ packages/solver/src/solver/search/seed.ts | 896 +++++ .../solver/src/solver/search/stability.ts | 161 + packages/solver/tests/workerSafety.test.ts | 31 +- 12 files changed, 3016 insertions(+), 2851 deletions(-) create mode 100644 packages/solver/src/solver/search/anneal.ts create mode 100644 packages/solver/src/solver/search/composition.ts create mode 100644 packages/solver/src/solver/search/placement.ts create mode 100644 packages/solver/src/solver/search/pooledCoolers.ts create mode 100644 packages/solver/src/solver/search/repair.ts create mode 100644 packages/solver/src/solver/search/rightSize.ts create mode 100644 packages/solver/src/solver/search/seed.ts create mode 100644 packages/solver/src/solver/search/stability.ts diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 4fa516d..713d76f 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -7,7 +7,11 @@ the terminal. ``` packages/solver/ - src/solver/ the engine: types, island split, simulation, distribution, search + src/solver/ the engine: types, island split, simulation, distribution, and + placementSearch.ts, the search's entry point (solveIsland) + src/solver/search/ one file per search stage: seed, anneal, composition, repair, + rightSize, pooledCoolers, and the placement and stability primitives + they share src/data/ the authored building tables, the anomaly table, the shipped island codes, unlock resolution, mapping a game export onto all of that src/encoding/ the blueprint wire format, and a reader for the game's export code @@ -65,9 +69,11 @@ gone; the reasoning is not. `DistributionScratch` **must never change a number.** It is an allocation optimization and nothing else. -Search _tuning_ constants (`SWAP_RADIUS`, `POLISH_SHARE`, and the rest) deliberately stay private -to `placementSearch.ts` rather than moving to `constants.ts`: they are meaningless apart from the -stage they tune. +Search _tuning_ constants (`SWAP_RADIUS`, `POLISH_SHARE`, and the rest) deliberately stay beside +the stage they tune rather than moving to `constants.ts`: they are meaningless apart from it. Each +stage of the search is a file of its own under `src/solver/search/`, and its constants are private +to that file; the budget shares that divide the time between stages live in `placementSearch.ts` +with `solveIsland`, and the one constant two stages share, `SWAP_RADIUS`, in `search/placement.ts`. ### `anomalies.ts` — the prestige rule changes diff --git a/packages/solver/CLAUDE.md b/packages/solver/CLAUDE.md index 60f988e..c80f468 100644 --- a/packages/solver/CLAUDE.md +++ b/packages/solver/CLAUDE.md @@ -300,7 +300,7 @@ CoolingPerSec` — it is what a cooler is worth and what the game shows for it, figures are read: a step of the walk writes one or two tiles and then simulates the island, which reads every occupied tile, so the write is some 25x less work and `simulateIsland` stays untouched. `put()` in - `placementSearch.ts` is the only way a building enters a placement, so the + `search/placement.ts` is the only way a building enters a placement, so the bonus cannot be missed at one of three dozen sites — and a miss would be silent, since the layout would simply be worth less than it is. @@ -585,8 +585,13 @@ is the entry point. `planSolve()` does the shared setup: **seed → repair → composition retarget → (annealing → repair)\* → pruning → right-sizing**. The starred pair repeats until the deadline: repair's slice is reserved before the layout exists and converges long before its cap, so what - it hands back becomes another short walk rather than idle time. The reasoning - for each stage is in the file header and `docs/SOLVER.md`; the short + it hands back becomes another short walk rather than idle time. Each stage + is a file under `search/` (`seed`, `anneal`, `composition`, `repair`, + `rightSize`, `pooledCoolers`, over the shared `placement` and `stability` + primitives); `placementSearch.ts` keeps `solveIsland`, the budget split, + the deterministic replay and `internals`, and stays the one module anything + outside imports the search from. The reasoning for each stage is in + `placementSearch.ts`'s header and `docs/SOLVER.md`; the short version is that seeding builds self-sufficient hubs and systematically misses layouts where hubs _share_ a cooler or reactor, and the other stages cover that from different directions. diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index 1506a63..22395d3 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -1,2837 +1,149 @@ -import { CHEBYSHEV_DIRECTIONS, EPS } from "./constants"; -import { - generatorEnergyRatio, - generatorPowerAndWaste, - generatorWasteRatio, - wasteIsCovered, -} from "./physics"; -import { buildIslandContext, type IslandContext } from "./context"; -import { generatorCapacities, isolationRoom } from "./island"; -import { Pacer, type IslandProgress } from "./pacer"; -import { AlternateCollector, isDistinctLayout, powerTies } from "./alternates"; -import { Rng, randomSeed } from "./rng"; -import { simulateIsland, type SimPlacedBuilding } from "./simulate"; -import type { - AnomalyDefinition, - EffectiveBuilding, - IslandLayout, - IslandSubGrid, - PlacedBuilding, - Placement, - SearchHooks, -} from "./types"; - -/** - * Placement search for a single island: - * - * seed -> repair -> composition retarget -> (annealing -> repair)* -> pruning - * -> right-sizing - * - * The `annealing -> repair` pair repeats until the deadline. Repair's slice is - * reserved before the layout it will run on exists, and on a post-anneal layout - * it almost always converges in one fruitless sweep, so whatever it hands back - * becomes another short walk rather than idle time at the end of the budget. - * - * Seed construction lays out self-sufficient "hubs" — one generator plus - * exactly the reactors and coolers it needs on its own. That is a good starting - * shape but a systematically incomplete one: real layouts do better by letting - * neighbouring hubs SHARE, one cooler absorbing waste from two generators or - * one reactor feeding two, which frees tiles for more producers. Nothing in - * seed construction can express that, and on the 67-tile map 1 island it caps - * out 7% below the best known layout. - * - * The two stages around it exist to cover that blind spot from different sides: - * - * - `greedyPolish` is steepest ascent over single-tile changes. Annealing alone - * was supposed to find these, and does on small islands, but on a large - * packed island almost every single-tile change costs several percent, so the - * walk drifts downhill and rarely climbs back — measured there, only 1 - * evaluation in 26,000 beat its own starting layout while 20 stable improving - * moves sat a single move away. - * - `targetCompositions` / `arrangeComposition` attack the other half. Deciding - * WHAT to build is a counting problem with an exact answer, so it is computed - * directly rather than searched for; the search is then left with the - * genuinely hard part, which is where to put it. - * - * Annealing keeps its place between them for the moves neither can make: it can - * cross a barrier that no single change improves upon. - * - * Right-sizing is the odd one out: it is not part of the search and cannot find - * a better layout. It runs once the layout is final and swaps each building for - * the smallest tier that still carries the load that layout gives it, because - * power is blind to the difference between a cooler running flat out and one - * running at 2% — and the player is not. - * - * **The stability rule.** A solved layout must be fully stable: every generator - * and direct producer it places has to actually run. A layout can score higher - * by parking reactor heat in a generator that is never cooled — nothing then - * has to cool that share of the waste — and it is still the wrong answer, so - * only stable layouts are ever recorded as best. "Pruning never reduces power" - * is NOT a valid invariant here; "the returned layout is stable" is. - * - * **Under an upgrade plan "stable" means stable at every step.** The search - * scores at the plan's target, but every gate that admits a layout — the best - * record, repair, the composition swaps, pruning, right-sizing, the tie - * shortlist and `stabilize` itself — also asks `planHolds`, so a layout that - * overheats at any tier on the way is never kept. Without a plan that call - * returns at once and nothing here changes. - */ - -const MAX_TOP_REACTOR_COPIES = 3; -const MAX_SECOND_REACTOR_COPIES = 3; -/** - * The cooling a pooled seed sizes its hubs against: cooling from nowhere, so - * that no hub ever needs a cooler beside it. It has to be `Infinity` and not - * merely large — a fit counts `ceil(waste / cooling)` coolers, and any finite - * figure makes that one, not none. `snapToAuthoredPrecision` passes it through - * and the pool's `factor` compares with `<`, so the scratch simulation the - * shared fit runs against it is sound. Never written onto a real placement. - */ -const POOL_COOLER_VALUE = Infinity; - -/** How many reactor tiers the local-repair pass considers per tile. */ -const POLISH_REACTOR_TIERS = 3; -/** - * How far from an applied repair move the cached gains are re-measured. A - * change on a tile is felt through its neighbours' supply and demand, so a - * tile two steps away shares a neighbour with it and one three steps away - * shares nothing — see `greedyPolish`. - */ -const POLISH_DIRTY_RADIUS = 2; -/** Share of the time budget reserved for repair before and after annealing. */ -const POLISH_SHARE = 0.3; -/** - * The least time the plain-sized seed's repair is given under a role - * isolation, when the sized seed's own repair took less (see `solveIsland`). - * A 20-tile island repairs in 3ms, and a cap that tight would be timer noise. - */ -const PLAIN_SEED_POLISH_MIN_MS = 20; -/** How many candidate compositions to try arranging, best-scoring first. */ -const COMPOSITION_TARGETS = 3; -/** Share of the time budget spent arranging those compositions. */ -const COMPOSITION_SHARE = 0.2; -/** Max Chebyshev distance between two tiles considered for an arrangement swap. */ -const SWAP_RADIUS = 2; -/** - * The closing repair is reserved a share of the budget up front, then handed - * back whatever it does not use (see `solveIsland`). These bound the handback: - * the repair is left twice its measured cost as headroom, but never more than - * half of what is left — on a big island one sweep costs seconds, and reserving - * twice that would strand more time than it protects. A reclaimed round shorter - * than the floor is not worth its own setup. - */ -const RECLAIM_POLISH_MARGIN = 2.0; -const RECLAIM_MAX_RESERVE_FRACTION = 0.5; -const RECLAIM_MIN_ROUND_SHARE = 0.01; -const RECLAIM_MIN_ROUND_MS = 50; - -export interface IslandSolution extends IslandLayout { - /** - * Other layouts this island's search finished tied with, best-power-first - * and never including the primary. Empty when the search found no second - * arrangement at the same power. See `alternates.ts` — they are gathered by - * watching the walk, and every one of them is stable and pruned exactly as - * the primary is. - */ - alternates?: IslandLayout[]; -} - -// --------------------------------------------------------------------------- -// Placement helpers -// --------------------------------------------------------------------------- - -function emptyPlacement(n: number): Placement { - return new Array(n).fill(null); -} - -/** - * Writes a building onto a tile, rated for that tile. - * - * The one way a building enters a placement, so that a terrain bonus cannot be - * missed at one of three dozen sites — and a miss would be silent, since the - * layout would simply be worth less than it is. `ctx.rate` is the identity - * under every rule but a terrain bonus, and idempotent under that one, so this - * is equally correct for a fresh placement, a swap between two tiles, and the - * restore that undoes a rejected move. - * - * Clearing a tile stays a plain `= null`: a null carries no rating, so there is - * nothing to resolve and nothing to get wrong. - */ -function put( - placement: Placement, - ctx: IslandContext, - tile: number, - building: EffectiveBuilding | null, -): void { - placement[tile] = ratedFor(ctx, tile, building); -} - -/** - * Exactly what `put` would write onto `tile` — the building as that tile rates - * it, or `null`. - * - * This is how a move asks "would writing this change anything?", and it has to - * be asked in the tile's own units: a placement holds RATED buildings, so - * comparing a plain roster entry against one is never equal on a scaled tile, - * and the guard that should have skipped the move instead writes back an - * identical object, runs a full `simulateIsland`, and hands `accept` a delta of - * zero — which it "accepts". No number moves, and the walk quietly spends a - * slice of a budget whose only product is steps per second. - * - * The comparison is exact rather than approximate because `ctx.rate` caches one - * object per (scale, roster entry) pair, so two tiles of the same scale holding - * the same building hold the *same* object — and it is cheap for the same - * reason: a lookup, not a multiply. - */ -function ratedFor( - ctx: IslandContext, - tile: number, - building: EffectiveBuilding | null, -): EffectiveBuilding | null { - return building === null ? null : ctx.rate(tile, building); -} - -function copyInto(dst: Placement, src: Placement): void { - for (let i = 0; i < dst.length; i++) dst[i] = src[i]; -} - -function isReactor(b: EffectiveBuilding): boolean { - return b.type === "reactor"; -} - -function isDirectProducer(b: EffectiveBuilding): boolean { - return b.type === "direct_producer"; -} - -/** Scratch for `generatorPowerAndWaste`, reused for the same reason as in `simulate.ts`. */ -const conversion = { power: 0.0, waste: 0.0 }; - -function chebyshev(ctx: IslandContext, a: number, b: number): number { - return Math.max( - Math.abs(ctx.xs[a] - ctx.xs[b]), - Math.abs(ctx.ys[a] - ctx.ys[b]), - ); -} - -/** The tile's Chebyshev-adjacent buildable neighbors that are still unclaimed. */ -function availableNeighbors( - tile: number, - available: Uint8Array, - ctx: IslandContext, -): number[] { - const out: number[] = []; - const nb = ctx.neighbors[tile]; - for (let k = 0; k < nb.length; k++) { - if (available[nb[k]]) out.push(nb[k]); - } - return out; -} - -function toPlacedBuildings(rows: SimPlacedBuilding[]): PlacedBuilding[] { - return rows.map((r) => ({ - x: r.x, - y: r.y, - buildingId: r.buildingId, - baseValue: r.baseValue, - powerGenerated: r.powerGenerated, - heatProduced: r.heatProduced, - heatConsumed: r.heatConsumed, - wasteHeatGenerated: r.wasteHeatGenerated, - coolingProvided: r.coolingProvided, - coolingReceived: r.coolingReceived, - })); -} - -// --------------------------------------------------------------------------- -// Stability -// --------------------------------------------------------------------------- - -/** - * Tiles holding a generator or direct producer that generates no power — - * either because it is overheating (waste above the cooling routed to it) or - * because it never received any heat at all. - * - * A layout containing one of these is not a valid answer: the goal is a fully - * stable grid where every producer runs, so these tiles are wasted at best and - * represent an overheating building at worst. - */ -function offlineProducers( - placement: Placement, - rows: SimPlacedBuilding[], -): number[] { - const offline: number[] = []; - for (let i = 0; i < rows.length; i++) { - const p = rows[i]; - const building = placement[p.idx]; - if (building === null) continue; - const producer = - building.type === "generator" || isDirectProducer(building); - if (producer && p.powerGenerated <= EPS) offline.push(p.idx); - } - return offline; -} - -/** - * A supplier sending out less than the tile it stands on was rated to carry — - * the walk's cue that a generator beside it would have somewhere to put the - * rest. - * - * Against `ratedValue`, never `baseValue`: `heatProduced` is what the - * distribution actually sent, capped by the tile's RATING, so measuring it - * against the authored tier compares a scaled delivery with an unscaled ceiling - * and only calls a reactor under-fed below 1/k fill for a tile rated k — on a - * Tidal shore k is its `multiplier`, under a maxed Stellar Forge the - * research's. The move then stops firing on much of what it exists for, with - * no number anywhere disagreeing. It is a named function so that the reading - * can be pinned by a test rather than only stated here. - */ -function isUnderFed(row: SimPlacedBuilding): boolean { - return row.heatProduced > EPS && row.heatProduced < row.ratedValue - EPS; -} - -/** Allocation-free `offlineProducers(...).length > 0`, for the search's inner loops. */ -function anyOfflineProducer( - placement: Placement, - rows: SimPlacedBuilding[], -): boolean { - for (let i = 0; i < rows.length; i++) { - const p = rows[i]; - const building = placement[p.idx]; - if (building === null) continue; - const producer = - building.type === "generator" || isDirectProducer(building); - if (producer && p.powerGenerated <= EPS) return true; - } - return false; -} - -/** - * An upgrade plan: the rosters a layout has to stay stable under besides the - * one it is scored at — the tiers the player holds today and every step on the - * way to the target. Each step is keyed by building id, because a placement - * holds the target roster's objects and the step's own figures are found by - * id; a building the step does not have stands on its tile as nothing. - * - * A step is simulated with the rating its tiles give it (`ctx.rate`), so a - * terrain bonus or a cooling pool applies at every step exactly as it does at - * the target. - */ -interface UpgradePlan { - steps: Map[]; - /** Scratch the step layout is written into, one per island. */ - buffer: Placement; -} - -/** - * Held beside the context rather than on it, so the context type — and every - * stage that takes one — is untouched when there is no plan, which is always - * the case for the fixtures. - */ -const upgradePlans = new WeakMap(); - -/** - * The producers that go offline under the first plan step the layout fails, or - * null when it runs at every step. Free when the island has no plan. - */ -function planFailure( - placement: Placement, - ctx: IslandContext, -): number[] | null { - const plan = upgradePlans.get(ctx); - if (plan === undefined) return null; - const buffer = plan.buffer; - for (const step of plan.steps) { - for (let t = 0; t < ctx.n; t++) { - const b = placement[t]; - const at = b === null ? undefined : step.get(b.id); - buffer[t] = at === undefined ? null : ctx.rate(t, at); - } - const sim = simulateIsland(buffer, ctx); - const offline = offlineProducers(buffer, sim.placements); - if (offline.length > 0) return offline; - } - return null; -} - -/** Whether the layout runs at every step of the island's plan. */ -function planHolds(placement: Placement, ctx: IslandContext): boolean { - return planFailure(placement, ctx) === null; -} - -interface StableLayout { - placement: Placement; - power: number; - rows: SimPlacedBuilding[]; -} - -/** - * Drops producers that generate no power until every remaining one is online. - * - * This has to iterate rather than delete them all in one pass: removing an - * offline generator hands the heat it was absorbing to its neighbours, which - * can push THEIR waste past what their coolers cover and take them offline in - * turn. Power can legitimately fall as a result — a layout that only scores - * well by parking heat in an overheating building is not a layout we want. - */ -function stabilize(placement: Placement, ctx: IslandContext): StableLayout { - const current = placement.slice(); - let sim = simulateIsland(current, ctx); - - for (;;) { - let offline = offlineProducers(current, sim.placements); - // Stable at the target is not enough under a plan: a producer that - // overheats at any step goes too, and so on until every step runs. - if (offline.length === 0) offline = planFailure(current, ctx) ?? []; - if (offline.length === 0) { - return { - placement: current, - power: sim.totalPower, - rows: sim.placements, - }; - } - for (const tile of offline) current[tile] = null; - sim = simulateIsland(current, ctx); - } -} - -// --------------------------------------------------------------------------- -// Seed construction -// --------------------------------------------------------------------------- - -interface HubFit { - generator: EffectiveBuilding; - reactor: EffectiveBuilding; - r: number; - c: number; - power: number; - /** The waste the hub makes at that fill — what a pooled seed charges for. */ - waste: number; -} - -function bestHubFit( - availableSlots: number, - generators: EffectiveBuilding[], - reactors: EffectiveBuilding[], - cooler: EffectiveBuilding, -): HubFit | null { - let best: HubFit | null = null; - for (const generator of generators) { - for (const reactor of reactors) { - const maxR = Math.min( - availableSlots, - Math.ceil(generator.effectiveValue / reactor.effectiveValue), - ); - for (let r = 1; r <= maxR; r++) { - const hIn = Math.min( - generator.effectiveValue, - r * reactor.effectiveValue, - ); - generatorPowerAndWaste(generator, hIn, conversion); - const waste = conversion.waste; - const c = - cooler.effectiveValue > 0 - ? Math.ceil(waste / cooler.effectiveValue) - : 0; - if (r + c <= availableSlots) { - const power = conversion.power; - if (best === null || power > best.power) { - best = { generator, reactor, r, c, power, waste }; - } - } - if (hIn >= generator.effectiveValue - EPS) break; - } - } - } - return best; -} - -interface DpFit { - dp: EffectiveBuilding; - c: number; - power: number; - waste: number; -} - -function bestDpFit( - availableSlots: number, - directProducers: EffectiveBuilding[], - cooler: EffectiveBuilding, -): DpFit | null { - let best: DpFit | null = null; - for (const dp of directProducers) { - const waste = dp.waste; - const c = - cooler.effectiveValue > 0 ? Math.ceil(waste / cooler.effectiveValue) : 0; - if (c > availableSlots) continue; - const power = dp.energy; - if (best === null || power > best.power) best = { dp, c, power, waste }; - } - return best; -} - -/** `itertools.combinations` over a list, in the same lexicographic order. */ -function* combinations(pool: number[], k: number): Generator { - const n = pool.length; - if (k > n || k < 0) return; - const idx = new Array(k); - for (let i = 0; i < k; i++) idx[i] = i; - for (;;) { - const out = new Array(k); - for (let i = 0; i < k; i++) out[i] = pool[idx[i]]; - yield out; - - let i = k - 1; - while (i >= 0 && idx[i] === i + n - k) i--; - if (i < 0) return; - idx[i]++; - for (let j = i + 1; j < k; j++) idx[j] = idx[j - 1] + 1; - } -} - -interface SharedFit { - power: number; - reactorTiles: number[]; - reactorTiers: EffectiveBuilding[]; - genPositions: number[]; - coolerPositions: number[]; - waste: number; -} - -/** - * A multi-generator arrangement around one reactor tile, for rosters where a - * single top-tier reactor overfills a generator: the surplus heat is only - * usable if two or more generators share that reactor, which the plain hub fit - * can never express. - */ -function bestSharedReactorFit( - tile: number, - neighbors: number[], - reactors: EffectiveBuilding[], - generator: EffectiveBuilding, - cooler: EffectiveBuilding, - ctx: IslandContext, - scratch: Placement, - pooled = false, -): SharedFit | null { - if (reactors.length === 0) return null; - const topReactor = reactors[0]; - const secondReactor = reactors.length > 1 ? reactors[1] : null; - - // Under a pool `cooler` is the phantom and `c` is 0, so the arrangement is - // simulated with no cooler beside it — and a coolerless board is offline. - // The pool is stood in for by the phantom on any tile outside the - // neighbourhood; where the island has none, the arrangement cannot be - // measured and is not offered. - let poolTile = -1; - if (pooled) { - const local = new Set(neighbors); - for (let t = 0; t < ctx.n; t++) { - if (t !== tile && !local.has(t)) { - poolTile = t; - break; - } - } - if (poolTile < 0) return null; - } - - const connectivity = new Map(); - for (const n of neighbors) { - let count = 0; - for (const m of neighbors) { - if (m !== n && chebyshev(ctx, n, m) <= 1) count++; - } - connectivity.set(n, count); - } - // Ascending tile index is ascending spatial key, so this is - // `(connectivity, spatial_key)` exactly. - const byConnectivityAsc = [...neighbors].sort( - (a, b) => connectivity.get(a)! - connectivity.get(b)! || a - b, - ); - - let best: SharedFit | null = null; - const maxK1 = Math.min(MAX_TOP_REACTOR_COPIES, 1 + neighbors.length); - - for (let k1 = 1; k1 <= maxK1; k1++) { - const extraTopNeeded = k1 - 1; - const maxK2 = - secondReactor !== null - ? Math.min(MAX_SECOND_REACTOR_COPIES, neighbors.length - extraTopNeeded) - : 0; - - for (let k2 = 0; k2 <= maxK2; k2++) { - const nExtraReactors = extraTopNeeded + k2; - if (nExtraReactors > neighbors.length) continue; - - let totalValue = k1 * topReactor.effectiveValue; - if (k2 && secondReactor) totalValue += k2 * secondReactor.effectiveValue; - - const g = Math.ceil(totalValue / generator.effectiveValue); - if (g < 2 || nExtraReactors + g + 1 > neighbors.length) continue; - - const absorbed = Math.min(totalValue, g * generator.effectiveValue); - const wasteTotal = absorbed * generatorWasteRatio(generator); - const c = - cooler.effectiveValue > 0 - ? Math.ceil(wasteTotal / cooler.effectiveValue) - : 0; - if (nExtraReactors + g + c > neighbors.length) continue; - - const extraPositions = byConnectivityAsc.slice(0, nExtraReactors); - const extraTopPositions = extraPositions.slice(0, extraTopNeeded); - const extraSecondPositions = extraPositions.slice( - extraTopNeeded, - nExtraReactors, - ); - const extraSet = new Set(extraPositions); - const pool = neighbors.filter((n) => !extraSet.has(n)); - - for (const genPositions of combinations(pool, g)) { - const genSet = new Set(genPositions); - const leftover = pool.filter((n) => !genSet.has(n)); - leftover.sort((a, b) => { - let adjA = 0; - let adjB = 0; - for (const gpos of genPositions) { - if (chebyshev(ctx, a, gpos) <= 1) adjA++; - if (chebyshev(ctx, b, gpos) <= 1) adjB++; - } - return adjB - adjA || a - b; - }); - const coolerPositions = leftover.slice(0, c); - - const touched: number[] = []; - put(scratch, ctx, tile, topReactor); - touched.push(tile); - for (const n of extraTopPositions) { - put(scratch, ctx, n, topReactor); - touched.push(n); - } - for (const n of extraSecondPositions) { - put(scratch, ctx, n, secondReactor); - touched.push(n); - } - for (const n of genPositions) { - put(scratch, ctx, n, generator); - touched.push(n); - } - for (const n of coolerPositions) { - put(scratch, ctx, n, cooler); - touched.push(n); - } - if (pooled) { - put(scratch, ctx, poolTile, cooler); - touched.push(poolTile); - } - - const { totalPower } = simulateIsland(scratch, ctx); - for (const n of touched) scratch[n] = null; - - if (totalPower > EPS && (best === null || totalPower > best.power)) { - const reactorTiles = [ - tile, - ...extraTopPositions, - ...extraSecondPositions, - ]; - const reactorTiers: EffectiveBuilding[] = [ - topReactor, - ...extraTopPositions.map(() => topReactor), - ...extraSecondPositions.map(() => secondReactor!), - ]; - best = { - power: totalPower, - reactorTiles, - reactorTiers, - genPositions, - coolerPositions, - waste: wasteTotal, - }; - } - } - } - } - - return best; -} - -/** A `SharedFit` with every tile spelled as a direction from its centre. */ -interface RelativeSharedFit { - power: number; - waste: number; - /** Direction index per reactor, or -1 for the centre tile itself. */ - reactorDirections: number[]; - reactorTiers: EffectiveBuilding[]; - genDirections: number[]; - coolerDirections: number[]; -} - -/** - * The shape of a tile's free neighbourhood: a bit per `CHEBYSHEV_DIRECTIONS` - * entry that holds a free tile, with the maps between tile and direction. - */ -function neighbourhoodShape( - ctx: IslandContext, - tile: number, - neighbors: number[], -): { - mask: number; - directionOf: Map; - tileAt: Int32Array; -} { - let mask = 0; - const directionOf = new Map(); - const tileAt = new Int32Array(CHEBYSHEV_DIRECTIONS.length).fill(-1); - for (const n of neighbors) { - const dx = ctx.xs[n] - ctx.xs[tile]; - const dy = ctx.ys[n] - ctx.ys[tile]; - for (let d = 0; d < CHEBYSHEV_DIRECTIONS.length; d++) { - if ( - CHEBYSHEV_DIRECTIONS[d][0] === dx && - CHEBYSHEV_DIRECTIONS[d][1] === dy - ) { - mask |= 1 << d; - directionOf.set(n, d); - tileAt[d] = n; - break; - } - } - } - return { mask, directionOf, tileAt }; -} - -function toRelativeFit( - fit: SharedFit, - directionOf: Map, -): RelativeSharedFit { - const direction = (t: number): number => directionOf.get(t) ?? -1; - return { - power: fit.power, - waste: fit.waste, - reactorDirections: fit.reactorTiles.map(direction), - reactorTiers: fit.reactorTiers, - genDirections: fit.genPositions.map(direction), - coolerDirections: fit.coolerPositions.map(direction), - }; -} - -function fromRelativeFit( - fit: RelativeSharedFit, - tile: number, - tileAt: Int32Array, -): SharedFit { - const at = (d: number): number => (d < 0 ? tile : tileAt[d]); - return { - power: fit.power, - waste: fit.waste, - reactorTiles: fit.reactorDirections.map(at), - reactorTiers: fit.reactorTiers, - genPositions: fit.genDirections.map(at), - coolerPositions: fit.coolerDirections.map(at), - }; -} - -interface CandidateEntry { - /** - * Power PER TILE CLAIMED, which is what candidates are ranked by — not raw - * power. A candidate that spends more tiles (e.g. a multi-reactor "shared" - * setup) can have higher raw power than a compact one-reactor hub while - * actually being a worse use of the island's limited tiles: it wins a single - * greedy round yet leaves the *rest* of the island worse off, since those - * extra tiles are no longer available for other, more efficient hubs. - * Ranking by power/tile keeps the greedy choice locally optimal in the - * currency that is actually scarce. - */ - powerPerTile: number; - kind: "hub" | "dp" | "shared"; - tile: number; - neighbors: number[]; - hub?: HubFit; - dp?: DpFit; - shared?: SharedFit; -} - -function constructSeed( - buildableOrder: number[], - ctx: IslandContext, - reactors: EffectiveBuilding[], - generators: EffectiveBuilding[], - coolers: EffectiveBuilding[], - directProducers: EffectiveBuilding[], - deadlineMs: number, - pooled = false, - sizeByIsolation = true, -): Placement { - const placement = emptyPlacement(ctx.n); - const available = new Uint8Array(ctx.n); - for (const tile of buildableOrder) available[tile] = 1; - let numAvailable = buildableOrder.length; - - const topReactor = reactors.length > 0 ? reactors[0] : null; - const topGenerator = generators.length > 0 ? generators[0] : null; - const topCooler = coolers.length > 0 ? coolers[0] : null; - - // Under a generator isolation a hub is sized with its generator at the - // ISOLATED rating, and no hub's generator is put beside another. Sized from - // the plain roster, a full-unlock hub is one ecto reactor filling one - // generator and the "shared" fit stands three generators round one reactor - // — every one of them crowded, on a board where an isolated generator takes - // two reactors and makes four times the power. That seed scored 32% of the - // Singularity bound on Event Horizon where the base seed scores 60% of its - // own (`npm run profile`), and the first repair had to double it. The fit - // is sized from the rated figure and the plain entry is what is written, so - // `simulateIsland` rates the tile from its neighbours as it always does. - // Off under every other rule, so the fixtures reproduce byte for byte. - // - // Measured with the survey (`--anomaly singularity_isolation --seed 1`): - // after the first repair the seed reads 78-88% of the bound on the eight - // largest islands against the plain one's 50-64%. At 15s x 5 runs that is - // a wash, -1.5% to +2.2% per map and every map inside its band; at 5s x 3 - // runs, where the budget binds, Event Horizon gains 14% and Shadowspire - // 18%, four maps 2-4%, Ash Bay and Magma Rift read 4% and 1% lower inside - // their bands. With generator7 at tier 3 — the roster where the bonus is - // worth most — every map is inside 0.8%. - const isolation = ctx.isolation; - const isolatedSeed = - sizeByIsolation && - isolation !== null && - isolation.role === "generator" && - topGenerator !== null && - ctx.rateIsolated(topGenerator, true).effectiveValue < - ctx.rateIsolated(topGenerator, false).effectiveValue; - const fitGenerators = isolatedSeed - ? generators.map((g) => ctx.rateIsolated(g, false)) - : generators; - const fitGenerator = fitGenerators.length > 0 ? fitGenerators[0] : null; - const plainOf = new Map(); - for (let i = 0; i < generators.length; i++) - plainOf.set(fitGenerators[i], generators[i]); - /** Whether a generator written on `tile` would crowd, or be crowded by, one. */ - const besideGenerator = (tile: number): boolean => { - const nb = ctx.neighbors[tile]; - for (let k = 0; k < nb.length; k++) { - const b = placement[nb[k]]; - if (b !== null && b.type === "generator") return true; - } - return false; - }; - - const canHub = - topReactor !== null && topGenerator !== null && topCooler !== null; - const canDp = directProducers.length > 0 && topCooler !== null; - const trySharedReactor = - canHub && - Math.ceil(topReactor!.effectiveValue / fitGenerator!.effectiveValue) >= 2; - - // Under a cooling pool a hub is sized with a phantom cooler that covers any - // waste from nowhere, so every neighbour slot goes to reactors and no cooler - // is written beside a hub. The cooling it really needs is charged as a - // board-wide debt instead: counted into each candidate's rank as the - // fractional tiles it will cost, reserved out of the free tiles so a hub can - // never crowd out the coolers that keep it online, and paid off after the - // hubs are down onto the least-connected free tiles — the scraps no hub can - // use, which under the pool are worth exactly what a well-connected tile is. - const fitCooler: EffectiveBuilding | null = - pooled && topCooler !== null - ? { ...topCooler, effectiveValue: POOL_COOLER_VALUE } - : topCooler; - const coolerValue = - topCooler !== null ? ctx.rateRole(topCooler).effectiveValue : 0; - /** Coolers the pool needs for `waste`, at the rated figure. */ - const coolersFor = (waste: number): number => - waste > EPS && coolerValue > 0 ? Math.ceil(waste / coolerValue) : 0; - /** What the board's producers are owed so far, from the simulation. */ - let debt = 0; - const actualWaste = (): number => { - const rows = simulateIsland(placement, ctx, true).placements; - let sum = 0; - for (let i = 0; i < rows.length; i++) sum += rows[i].wasteHeatGenerated; - return sum; - }; - /** Whether claiming `tiles` still leaves room for the coolers `waste` needs. */ - const reserves = (tiles: number, waste: number): boolean => - !pooled || numAvailable - tiles >= coolersFor(debt + waste); - /** Tiles a candidate charges for: its own, plus the pool's share under one. */ - const cost = (tiles: number, waste: number): number => - pooled ? tiles + waste / coolerValue : tiles; - /** The tiles each claim took, newest last, for the pooled backstop. */ - const groups: number[][] = []; - - const hubFitCache = new Map(); - const dpFitCache = new Map(); - const sharedScratch = emptyPlacement(ctx.n); - - // A shared fit is simulated on an otherwise empty board, so it is a function - // of the neighbourhood's *shape* alone — which of the eight directions hold a - // free tile — as long as every tile rates a building the same: the tile - // order the distribution runs in is the spatial order, which the same shape - // reproduces wherever it sits. That is true under a uniform rating and under - // a role scale, which rates every tile alike; a terrain bonus is per tile and - // keeps the uncached path. Memoised by that shape, because this is the whole - // cost of the seed: it used to re-simulate every generator combination for - // every stale tile on every round, 0.5-0.8s on the largest shipped islands. - const sharedFitByShape = - ctx.uniformRating || ctx.anomaly.rule === "shared_cooling" - ? new Map() - : null; - const sharedFit = (tile: number, neighbors: number[]): SharedFit | null => { - if (sharedFitByShape === null) { - return bestSharedReactorFit( - tile, - neighbors, - reactors, - fitGenerator!, - fitCooler!, - ctx, - sharedScratch, - pooled, - ); - } - const shape = neighbourhoodShape(ctx, tile, neighbors); - let relative = sharedFitByShape.get(shape.mask); - if (relative === undefined) { - const fit = bestSharedReactorFit( - tile, - neighbors, - reactors, - fitGenerator!, - fitCooler!, - ctx, - sharedScratch, - pooled, - ); - relative = fit === null ? null : toRelativeFit(fit, shape.directionOf); - sharedFitByShape.set(shape.mask, relative); - } - return relative === null - ? null - : fromRelativeFit(relative, tile, shape.tileAt); - }; - - const hubFit = (slots: number): HubFit | null => { - let fit = hubFitCache.get(slots); - if (fit === undefined) { - fit = canHub - ? bestHubFit(slots, fitGenerators, reactors, fitCooler!) - : null; - hubFitCache.set(slots, fit); - } - return fit; - }; - - const dpFit = (slots: number): DpFit | null => { - let fit = dpFitCache.get(slots); - if (fit === undefined) { - fit = canDp ? bestDpFit(slots, directProducers, fitCooler!) : null; - dpFitCache.set(slots, fit); - } - return fit; - }; - - const candidateCache = new Array(ctx.n).fill(null); - const stale = new Uint8Array(ctx.n); - for (const tile of buildableOrder) stale[tile] = 1; - - while (numAvailable > 0) { - if (performance.now() >= deadlineMs) break; - - const staleList = buildableOrder.filter((tile) => stale[tile] === 1); - for (const tile of staleList) { - if (performance.now() >= deadlineMs) break; - - const neighbors = availableNeighbors(tile, available, ctx); - let entry: CandidateEntry | null = null; - const consider = (candidate: CandidateEntry) => { - if (entry === null || candidate.powerPerTile > entry.powerPerTile) - entry = candidate; - }; - // A hub's generator stands on `tile`; beside a placed generator it - // would be crowded, and crowd that one. Only the stale tiles are - // re-read here and a claim marks every tile beside it stale, so this - // sees each generator as it lands. - const crowds = isolatedSeed && besideGenerator(tile); - - // Under a pool the fit shrinks until its reactors and its coolers both - // fit in what is left; under the base rules the first fit is the one. - let hub = crowds ? null : hubFit(neighbors.length); - while (hub !== null && !reserves(1 + hub.r, hub.waste)) { - hub = hub.r > 1 ? hubFit(hub.r - 1) : null; - } - if (hub !== null) { - const tilesUsed = 1 + hub.r + hub.c; - if (hub.power > EPS && tilesUsed > 0) { - consider({ - powerPerTile: hub.power / cost(tilesUsed, hub.waste), - kind: "hub", - tile, - neighbors, - hub, - }); - } - } - - const dp = dpFit(neighbors.length); - if (dp !== null && reserves(1, dp.waste)) { - const tilesUsed = 1 + dp.c; - if (dp.power > EPS && tilesUsed > 0) { - consider({ - powerPerTile: dp.power / cost(tilesUsed, dp.waste), - kind: "dp", - tile, - neighbors, - dp, - }); - } - } - - if (trySharedReactor && neighbors.length >= 3) { - const shared = sharedFit(tile, neighbors); - if ( - shared !== null && - !(isolatedSeed && shared.genPositions.some(besideGenerator)) - ) { - const tilesUsed = - shared.reactorTiles.length + - shared.genPositions.length + - shared.coolerPositions.length; - if (tilesUsed > 0 && reserves(tilesUsed, shared.waste)) { - consider({ - powerPerTile: shared.power / cost(tilesUsed, shared.waste), - kind: "shared", - tile, - neighbors, - shared, - }); - } - } - } - - candidateCache[tile] = entry; - } - - stale.fill(0); - - let best: CandidateEntry | null = null; - for (const tile of buildableOrder) { - if (!available[tile]) continue; - const entry = candidateCache[tile]; - if ( - entry !== null && - (best === null || entry.powerPerTile > best.powerPerTile) - ) { - best = entry; - } - } - if (best === null) break; - - if (pooled) { - // The debt has grown since a candidate untouched by the last claim was - // sized, so it may no longer leave room for its coolers. Re-size every - // open tile against the debt as it stands: a candidate sized against the - // current debt passes this at once, so the loop cannot spin. - const tiles = - best.kind === "hub" - ? 1 + best.hub!.r - : best.kind === "dp" - ? 1 - : best.shared!.reactorTiles.length + - best.shared!.genPositions.length; - const waste = - best.kind === "hub" - ? best.hub!.waste - : best.kind === "dp" - ? best.dp!.waste - : best.shared!.waste; - if (!reserves(tiles, waste)) { - for (const tile of buildableOrder) if (available[tile]) stale[tile] = 1; - continue; - } - } - - const claimed: number[] = []; - if (best.kind === "hub") { - const { generator, reactor, r, c } = best.hub!; - put(placement, ctx, best.tile, plainOf.get(generator) ?? generator); - claimed.push(best.tile); - for (let i = 0; i < r; i++) { - put(placement, ctx, best.neighbors[i], reactor); - claimed.push(best.neighbors[i]); - } - for (let i = r; i < r + c; i++) { - put(placement, ctx, best.neighbors[i], topCooler); - claimed.push(best.neighbors[i]); - } - } else if (best.kind === "shared") { - const shared = best.shared!; - for (let i = 0; i < shared.reactorTiles.length; i++) { - put(placement, ctx, shared.reactorTiles[i], shared.reactorTiers[i]); - claimed.push(shared.reactorTiles[i]); - } - for (const n of shared.genPositions) { - put(placement, ctx, n, topGenerator); - claimed.push(n); - } - for (const n of shared.coolerPositions) { - put(placement, ctx, n, topCooler); - claimed.push(n); - } - } else { - const { dp, c } = best.dp!; - put(placement, ctx, best.tile, dp); - claimed.push(best.tile); - for (let i = 0; i < c; i++) { - put(placement, ctx, best.neighbors[i], topCooler); - claimed.push(best.neighbors[i]); - } - } - - for (const tile of claimed) { - if (available[tile]) { - available[tile] = 0; - numAvailable--; - } - candidateCache[tile] = null; - } - for (const tile of claimed) { - const nb = ctx.neighbors[tile]; - for (let k = 0; k < nb.length; k++) { - if (available[nb[k]]) stale[nb[k]] = 1; - } - } - if (pooled) { - groups.push(claimed); - // The simulated figure rather than the sum of the fits' estimates: two - // hubs down beside each other share reactors, and a generator fed by a - // neighbour's surplus makes waste no fit counted. - debt = actualWaste(); - } - } - - if (pooled && topCooler !== null) { - // Pay the debt off, least-connected tile first: a one-tile scrap holds a - // cooler as well as any tile does, and nothing else can use it. Ascending - // index breaks ties so the fill is a function of the board alone. - const free = buildableOrder - .filter((tile) => available[tile] === 1) - .sort( - (a, b) => ctx.neighbors[a].length - ctx.neighbors[b].length || a - b, - ); - let cooling = 0; - let next = 0; - const payDown = () => { - while (next < free.length && !wasteIsCovered(debt, cooling)) { - const tile = free[next++]; - put(placement, ctx, tile, topCooler); - cooling += placement[tile]!.effectiveValue; - } - }; - payDown(); - - // A short pool is an all-offline board, and `stabilize` would then delete - // every producer. So the board has to come up here: one more cooler while - // there is a tile for it, and failing that the newest hub is taken back - // and its tiles spent on the pool instead. - while (debt > EPS && simulateIsland(placement, ctx).totalPower <= EPS) { - if (next < free.length) { - const tile = free[next++]; - put(placement, ctx, tile, topCooler); - cooling += placement[tile]!.effectiveValue; - continue; - } - const group = groups.pop(); - if (group === undefined) break; - for (const tile of group) placement[tile] = null; - for (const tile of group) free.push(tile); - debt = actualWaste(); - payDown(); - } - } - - return placement; -} - -/** - * `sizeByIsolation` is `constructSeed`'s: false builds the plain-sized seed - * under a role isolation, which `solveIsland` compares against the sized one. - * It means nothing under any other rule. - */ -function constructMultiStartSeed( - ctx: IslandContext, - reactors: EffectiveBuilding[], - generators: EffectiveBuilding[], - coolers: EffectiveBuilding[], - directProducers: EffectiveBuilding[], - deadlineMs: number, - sizeByIsolation = true, -): Placement { - const buildable = Array.from(ctx.tiles); - - const passes = ( - pooled: boolean, - sizeByIsolation = true, - ): { seed: Placement; power: number } => { - let bestSeed = constructSeed( - buildable, - ctx, - reactors, - generators, - coolers, - directProducers, - deadlineMs, - pooled, - sizeByIsolation, - ); - let bestPower = simulateIsland(bestSeed, ctx).totalPower; - - if (bestPower <= EPS) { - // The deadline elapsed mid-construction and left a seed that powers - // nothing. One full greedy pass costs about 25ms on the largest island - // in the game (`npm run profile`), so finish it: refining a degenerate - // seed wastes the whole budget and can return an empty layout for a - // solvable island. - bestSeed = constructSeed( - buildable, - ctx, - reactors, - generators, - coolers, - directProducers, - performance.now() + 1000, - pooled, - sizeByIsolation, - ); - bestPower = simulateIsland(bestSeed, ctx).totalPower; - } - - if (performance.now() >= deadlineMs - 50 || buildable.length < 6) - return { seed: bestSeed, power: bestPower }; - - const reversed = [...buildable].reverse(); - const seedRev = constructSeed( - reversed, - ctx, - reactors, - generators, - coolers, - directProducers, - deadlineMs, - pooled, - sizeByIsolation, - ); - const powerRev = simulateIsland(seedRev, ctx).totalPower; - if (powerRev > bestPower + EPS) { - bestPower = powerRev; - bestSeed = seedRev; - } - - if (performance.now() < deadlineMs - 50) { - const rng = new Rng(42); - const shuffled = [...buildable]; - rng.shuffle(shuffled); - const seedRand = constructSeed( - shuffled, - ctx, - reactors, - generators, - coolers, - directProducers, - deadlineMs, - pooled, - sizeByIsolation, - ); - const powerRand = simulateIsland(seedRand, ctx).totalPower; - if (powerRand > bestPower + EPS) { - bestPower = powerRand; - bestSeed = seedRand; - } - } - - return { seed: bestSeed, power: bestPower }; - }; - - const local = passes(false, sizeByIsolation); - if (ctx.anomaly.rule !== "shared_cooling") return local.seed; - - // Under a pool the same three orders are run engine-first as well, and the - // strongest seed of the six starts the search. The local passes stay in: - // they are what the search started from before the pooled ones existed, and - // a pooled seed that comes back weaker is not an improvement. - const pooled = passes(true); - return pooled.power > local.power + EPS ? pooled.seed : local.seed; -} - -// --------------------------------------------------------------------------- -// Annealing -// --------------------------------------------------------------------------- - -function randomCandidate( - pools: EffectiveBuilding[][], - rng: Rng, -): EffectiveBuilding | null { - if (pools.length === 0 || rng.random() < 0.15) return null; - const pool = rng.choice(pools); - if (pool.length === 1 || rng.random() < 0.7) return pool[0]; - return rng.choice(pool); -} - -/** - * `maxSteps` caps the walk by iteration count and, when set, drives the - * temperature schedule by `step / maxSteps` instead of elapsed time. With a - * fixed `Rng` and a `timeBudgetMs` generous enough that the deadline never - * fires first, the walk is then fully deterministic: same seed, same result, - * bit for bit. That is what the golden fixtures stand on. The solve pipeline - * itself never sets it — wall time is the right budget in production, where - * per-step cost varies by island. - */ -async function hillClimb( - seed: Placement, - ctx: IslandContext, - reactors: EffectiveBuilding[], - generators: EffectiveBuilding[], - coolers: EffectiveBuilding[], - directProducers: EffectiveBuilding[], - timeBudgetMs: number, - rng: Rng, - pacer: Pacer, - maxSteps?: number, - collector?: AlternateCollector, -): Promise { - if (timeBudgetMs <= 0 || ctx.n === 0) return seed; - - const current = seed.slice(); - let sim = simulateIsland(current, ctx); - let currentPower = sim.totalPower; - let currentPlacements = sim.placements; - - // The walk itself is free to pass through layouts containing overheating - // buildings — they are often one move away from good ones — but only a STABLE - // layout may be recorded as the best. Raw power alone would happily settle on - // a layout that parks heat in a permanently offline generator, which then has - // to be dismantled at the end, ending up worse than the best stable layout - // the search already walked past. - const initial = stabilize(current, ctx); - let bestPlacement = initial.placement; - let bestPower = initial.power; - // The layout the walk starts from is itself an answer at this power, and the - // shortlist has to hold one before it can recognise a tie with it. - collector?.offer(initial.power, initial.placement); - // Highest raw score seen so far, used only to decide when an unstable layout - // is promising enough to be worth stabilizing and re-scoring. - let bestRawPower = currentPower; - let stall = 0; - - const recordIfBest = ( - power: number, - snapshot: Placement, - rows: SimPlacedBuilding[], - ): void => { - if (power <= bestPower + EPS) { - // Not an improvement — but a layout that *ties* the best is a second - // answer worth offering the user, so it goes on the shortlist. The - // checks are ordered by what they cost: `full` is a size comparison, - // `accepts` reads the layout's tiles, and only then does the stability - // test walk every row. A converged walk ties its best constantly and - // nearly all of those are near copies of a layout already held, so - // `accepts` is what keeps this off the hot path once the shortlist has - // stopped growing. - if ( - collector !== undefined && - !collector.full && - powerTies(power, collector.power) && - collector.accepts(snapshot) && - !anyOfflineProducer(snapshot, rows) && - planHolds(snapshot, ctx) - ) { - collector.offer(power, snapshot); - } - return; - } - - if (!anyOfflineProducer(snapshot, rows) && planHolds(snapshot, ctx)) { - bestPower = power; - bestPlacement = snapshot.slice(); - collector?.offer(power, snapshot); - stall = 0; - return; - } - - // Unstable. Only worth the extra simulations when it is a new raw - // high-water mark — otherwise the walk would re-stabilize the same - // unreachable peak on every visit. - if (power > bestRawPower + EPS) { - bestRawPower = power; - const stabilized = stabilize(snapshot, ctx); - if (stabilized.power > bestPower + EPS) { - bestPower = stabilized.power; - bestPlacement = stabilized.placement; - collector?.offer(stabilized.power, stabilized.placement); - stall = 0; - } - } - }; - - const topCooler = coolers.length > 0 ? coolers[0] : null; - const topGenerator = generators.length > 0 ? generators[0] : null; - const pools = [reactors, generators, coolers, directProducers].filter( - (p) => p.length > 0, - ); - const startMs = performance.now(); - const deadlineMs = startMs + timeBudgetMs; - - // Temperature is scaled to the size of one MOVE, not to the island's total - // output. Changing a single tile is worth roughly one generator's power - // wherever it happens, so scaling by total power made large islands run far - // too hot: on the 67-tile map 1 island, ~23% of moves that each cost 7% of - // the layout were being accepted, and the walk never climbed back. - // - // It is read off the PLAIN roster, so under a rule that rates a tile above it - // the walk runs colder than this calibration intends — a move on a Tidal - // shore is worth its `multiplier` times one here. Scaling it by - // `islandRatingCeiling` was measured: Tidal (the pre-nerf - // `tidal_ascendancy_legacy`) on map 3 at 15s over three seeds went - // 176/178/179AC to 179/181/178AC, and Singularity at 30s over five seeds on - // map 3 142/140/142/142/140AC to - // 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to - // 267/271/271/271/274AC — inside run-to-run noise, so the plain figure is kept. - // Anyone re-measuring should use a longer budget and more seeds than that. - const moveScale = - generators.length > 0 - ? generators[0].effectiveValue * generatorEnergyRatio(generators[0]) - : 1.0; - const tStart = Math.max(moveScale * 0.1, 1.0); - const tMin = 1e-4; - let temperature = tStart; - - // Snap back to the best layout after a long run of no improvement. Without - // this, one unlucky sequence of accepted downhill moves strands the walk in a - // region it cannot climb out of for the rest of the budget. - const stallLimit = Math.max(2000, ctx.n * 200); - - let step = 0; - const batchMask = 127; - - /** Accepts or rolls back a trial move, Metropolis-style. */ - const accept = (power: number, rows: SimPlacedBuilding[]): boolean => { - const delta = power - currentPower; - const exponent = temperature > 0 ? delta / temperature : -100.0; - if ( - delta > EPS || - (exponent > -50.0 && rng.random() < Math.exp(exponent)) - ) { - currentPower = power; - currentPlacements = rows; - recordIfBest(power, current, rows); - return true; - } - return false; - }; - - for (;;) { - if (maxSteps !== undefined && step >= maxSteps) break; - - if ((step & batchMask) === 0) { - const now = performance.now(); - if (now >= deadlineMs) break; - - if (pacer.due(now)) { - await pacer.pump(now, () => islandProgress(bestPlacement, ctx)); - if (pacer.stopRequested) break; - } - - const progress = - maxSteps !== undefined - ? Math.min(1.0, step / maxSteps) - : Math.min(1.0, Math.max(0.0, (now - startMs) / timeBudgetMs)); - temperature = tStart * Math.pow(tMin / tStart, progress); - - if (stall >= stallLimit) { - copyInto(current, bestPlacement); - sim = simulateIsland(current, ctx); - currentPower = sim.totalPower; - currentPlacements = sim.placements; - stall = 0; - } - } - - step += 1; - stall += 1; - const moveType = rng.random(); - - if (moveType < 0.15 && currentPlacements.length > 0) { - const uncooled: SimPlacedBuilding[] = []; - const wastedReactors: SimPlacedBuilding[] = []; - for (const p of currentPlacements) { - if ( - p.wasteHeatGenerated > EPS && - !wasteIsCovered(p.wasteHeatGenerated, p.coolingReceived) - ) { - uncooled.push(p); - } - if (isUnderFed(p)) { - wastedReactors.push(p); - } - } - - if (uncooled.length > 0 && topCooler !== null && rng.random() < 0.6) { - const target = rng.choice(uncooled); - const adj = ctx.neighbors[target.idx]; - // Beside the starved producer, which is the only place a cooler can - // reach it. Under a pool any tile would do and a short pool starves - // every producer at once, so this writes over a random occupied - // neighbour there — often the reactor feeding the very producer it - // means to save. Aiming it at an empty tile instead was measured on - // maps 3 and 7 at 15s over three seeds and changed nothing, so the one - // shape is kept. - if (adj.length > 0) { - const tile = adj[rng.int(adj.length)]; - const old = current[tile]; - // In the tile's units, or the guard never fires on a rated tile — - // see `ratedFor`. - if (ratedFor(ctx, tile, topCooler) !== old) { - put(current, ctx, tile, topCooler); - const trial = simulateIsland(current, ctx); - if (!accept(trial.totalPower, trial.placements)) - put(current, ctx, tile, old); - continue; - } - } - } else if ( - wastedReactors.length > 0 && - topGenerator !== null && - rng.random() < 0.6 - ) { - const target = rng.choice(wastedReactors); - const adj = ctx.neighbors[target.idx]; - if (adj.length > 0) { - const tile = adj[rng.int(adj.length)]; - const old = current[tile]; - if (ratedFor(ctx, tile, topGenerator) !== old) { - put(current, ctx, tile, topGenerator); - const trial = simulateIsland(current, ctx); - if (!accept(trial.totalPower, trial.placements)) - put(current, ctx, tile, old); - continue; - } - } - } - } - - if (moveType < 0.5 && ctx.n >= 2) { - const tile1 = rng.int(ctx.n); - const adj = ctx.neighbors[tile1]; - let tile2: number; - if (adj.length > 0 && rng.random() < 0.7) { - tile2 = adj[rng.int(adj.length)]; - } else { - tile2 = rng.int(ctx.n); - while (tile2 === tile1) tile2 = rng.int(ctx.n); - } - - const val1 = current[tile1]; - const val2 = current[tile2]; - // A swap that writes each tile what it already holds. On one scale class - // that is `val1 === val2`; across two it is not, because the same roster - // entry is a different object on each — so the test is on what the writes - // would land, which covers both. - if ( - ratedFor(ctx, tile1, val2) === val1 && - ratedFor(ctx, tile2, val1) === val2 - ) { - continue; - } - - put(current, ctx, tile1, val2); - put(current, ctx, tile2, val1); - - const trial = simulateIsland(current, ctx); - if (!accept(trial.totalPower, trial.placements)) { - put(current, ctx, tile1, val1); - put(current, ctx, tile2, val2); - } - continue; - } - - const tile = rng.int(ctx.n); - const old = current[tile]; - const replacement = randomCandidate(pools, rng); - // The commonest move in the walk, and the commonest no-op: `randomCandidate` - // returns the top tier of a pool with p ~ 0.7, so a packed island proposes - // the building a tile already holds constantly. - if (ratedFor(ctx, tile, replacement) === old) continue; - - put(current, ctx, tile, replacement); - const trial = simulateIsland(current, ctx); - if (!accept(trial.totalPower, trial.placements)) current[tile] = old; - } - - return bestPlacement; -} - -// --------------------------------------------------------------------------- -// Composition retargeting -// --------------------------------------------------------------------------- - -interface ScoredComposition { - power: number; - composition: EffectiveBuilding[]; -} - -/** - * The best MULTISETS of buildings for an island of this size, ignoring where - * they go — highest theoretical power first. - * - * Which buildings to use is a tiny counting problem, not a search problem. For - * counts (nReactor, nGenerator, nCooler) summing to the tile budget, the layout - * can convert at most `min(reactor output, nGenerator * H_max, nCooler * C / - * WASTE_RATIO)` of heat, so enumerating the O(tiles^2) splits and greedily - * filling the reactor tiles with the largest tiers that stay inside that budget - * gives the exact best composition. On the 67-tile map 1 island this returns - * 13 neuro + 1 sauron_eye + 14 generator6 + 39 cooler6 — the known optimum — in - * a few milliseconds, where the search alone plateaued 1.6% below it. - * - * Placement can still make a composition unachievable (a generator can only be - * fed by ADJACENT reactors), which is why several are returned, each with the - * power it would reach if it could be arranged perfectly: the caller tries them - * in order, and can skip any that could not beat what it already has. - */ -function targetCompositions( - tileCount: number, - reactors: EffectiveBuilding[], - generators: EffectiveBuilding[], - coolers: EffectiveBuilding[], - generatorCapacity: Float64Array | null, - topK = COMPOSITION_TARGETS, -): ScoredComposition[] { - if ( - reactors.length === 0 || - generators.length === 0 || - coolers.length === 0 || - tileCount < 3 - ) { - return []; - } - - const generator = generators[0]; - const cooler = coolers[0]; - // Only the `topK` best are kept as they are found, and a composition's - // array is built only once it has earned a place. Materialising all - // O(tiles^2) of them and sorting afterwards cost 80-130ms on the largest - // shipped islands, nearly all of it allocation. Insertion goes after every - // entry of equal power, which is exactly what the stable descending sort it - // replaces produced: equal scores in enumeration order. - const scored: ScoredComposition[] = []; - const earnsPlace = (power: number): boolean => - scored.length < topK || power > scored[scored.length - 1].power; - const keep = (entry: ScoredComposition): void => { - let at = scored.length; - while (at > 0 && scored[at - 1].power < entry.power) at--; - scored.splice(at, 0, entry); - if (scored.length > topK) scored.pop(); - }; - - for (let nGen = 1; nGen < tileCount - 1; nGen++) { - for (let nCool = 1; nCool < tileCount - nGen; nCool++) { - const nReact = tileCount - nGen - nCool; - if (nReact < 1) continue; - - // Most heat this many generators, and this much cooling, can carry. - const wasteRatio = generatorWasteRatio(generator); - if (wasteRatio <= 0) continue; - const budget = Math.min( - generatorCapacity === null - ? nGen * generator.effectiveValue - : generatorCapacity[nGen], - (nCool * cooler.effectiveValue) / wasteRatio, - ); - - // Two ways to fill the reactor tiles, and which one wins depends on what - // is actually binding. - // - // Packing them with the largest tier maximizes heat. Overshooting the - // budget costs nothing — surplus heat is simply never absorbed and power - // stays capped — so this is what to do when generator or cooling capacity - // is the limit. - // - // Stepping down through tiers to land just under the budget instead - // matters when COOLING is the limit: the all-or-nothing rule shuts a - // generator down entirely if its waste outruns the cooling reaching it, - // so a smaller top-up reactor can be worth more than a larger one. - const fills: EffectiveBuilding[][] = [ - new Array(nReact).fill(reactors[0]), - ]; - - let heat = 0.0; - const mix: EffectiveBuilding[] = []; - for (const tier of reactors.slice(0, POLISH_REACTOR_TIERS)) { - if (tier.effectiveValue <= 0) continue; - const take = Math.min( - nReact - mix.length, - Math.floor((budget - heat) / tier.effectiveValue), - ); - if (take > 0) { - heat += take * tier.effectiveValue; - for (let i = 0; i < take; i++) mix.push(tier); - } - } - if (mix.length === nReact && !sameFill(mix, fills[0])) fills.push(mix); - - for (const fill of fills) { - let fillHeat = 0.0; - for (const b of fill) fillHeat += b.effectiveValue; - const absorbed = Math.min(fillHeat, budget); - const power = absorbed * generatorEnergyRatio(generator); - if (power > EPS && earnsPlace(power)) { - const composition = fill.slice(); - for (let i = 0; i < nGen; i++) composition.push(generator); - for (let i = 0; i < nCool; i++) composition.push(cooler); - keep({ power, composition }); - } - } - } - } - - return scored; -} - -/** - * How much heat `nGen` generator tiles on THIS island can take in, or null - * under every rule that leaves the count alone. - * - * `targetCompositions` decides what to build by counting, and the count it - * wants is a function of what a generator is worth. Under a role isolation - * that is two numbers rather than one — a generator with no generator beside - * it takes 4x, one with 0.8x — and which a tile gets is decided by the - * layout, so neither belongs in the pool the stage draws on. The roster cannot - * express it; the island can, and `isolationRoom` is how much of the bonus its - * shape has room for. - * - * Both halves are load-bearing. Sizing at the bonus alone asks Gale Hills at - * generator7 tier 1 for 20 generators on an island that can keep 15 apart, and - * `arrangeComposition` cannot spread that many without most of them touching. - * With the room folded in it asks for 16, and over ten runs at 15s the mean - * goes 44.6AC to 46.2AC; the best either reaches is 49.8AC, fifteen generators - * every one isolated. Over three seeds it is worth +12-14% on maps 7 and 8 and - * costs 1-4% on maps 2, 5 and 6 — a net gain, not a uniform one. - * - * The same table the bound runs on (`island.ts`), and deliberately so — one - * reading of what a board has room for, rather than a second one here to drift - * against it. It is built from the island's own ratings rather than the - * capped figures the bound uses: a bound may relax adjacency away, a target - * has to be something the board can hold. - */ -function generatorCapacityTable( - island: IslandSubGrid, - ctx: IslandContext, - generator: EffectiveBuilding | undefined, -): Float64Array | null { - if (generator === undefined) return null; - - const isolated = ctx.rateIsolated(generator, false).effectiveValue; - const crowded = ctx.rateIsolated(generator, true).effectiveValue; - // Equal under every other rule, and under one that rates a crowded building - // no lower there is no ceiling to respect: the count is linear again. - if (crowded >= isolated) return null; - - return generatorCapacities(ctx.n, isolated, { - gTake: crowded, - room: isolationRoom(island), - }); -} - -/** - * The largest factor anything on this island can be rated at — 1 under the rules - * that leave the roster alone. - * - * `targetCompositions` scores a target from the plain roster, and it is right to: - * which buildings to use is a counting problem over the roster, and the counting - * is the same whatever the tiles are worth. But the layout the caller compares it - * against has been rated tile by tile, so the two figures are in different units, - * and a target's ceiling read as-is says "this cannot beat what I have" about - * every target on the list. The gate is a `break`, so the whole stage goes with - * it — silently, and hardest under the anomaly that most needs it: on island3 - * under Tidal a 0.6s run already returns 1.68e23 against a top target of - * 1.49e23, so the stage never ran at any realistic budget. - * - * Scaling the target by one island-wide factor is the move `estimateTotalMaxPower` - * makes to keep its bound a bound wherever its mask cannot say which tiles carry - * the factor (`islandMaxScale`), and it is sound for the same reason: a - * composition's power is positively homogeneous of degree 1 in - * the roster's figures, so rating the whole island at its best tile is an upper - * bound on rating each tile at its own. Loose where only part of an island - * qualifies, which is the right way to be wrong here — a ceiling that is too low - * skips a stage that would have helped, while one that is too high costs a few - * arrangement attempts that fail to beat the layout in hand. - * - * It is asked of the context rather than read off the anomaly so that a rule - * resolved per tile (`rate`) and one resolved per layout (`rateIsolated`) are - * both answered by the code that actually applies them, and a fifth rule shape - * needs nothing here at all. - */ -function islandRatingCeiling( - ctx: IslandContext, - probes: readonly EffectiveBuilding[], - sizedByIsolation: boolean, -): number { - if (ctx.uniformRating && (ctx.isolation === null || sizedByIsolation)) - return 1; - - let ceiling = 1; - const consider = (factor: number): void => { - if (factor > ceiling) ceiling = factor; - }; - - for (const probe of probes) { - if (probe.effectiveValue <= 0) continue; - if (!ctx.uniformRating) { - for (let tile = 0; tile < ctx.n; tile++) { - consider(ctx.rate(tile, probe).effectiveValue / probe.effectiveValue); - } - } - if (ctx.isolation !== null && !sizedByIsolation) { - // Both variants, because which one a tile gets is a function of the - // layout: a search free to keep generators apart rates every one of them - // at the bonus. The same reasoning `islandMaxScale` carries. - consider( - ctx.rateIsolated(probe, false).effectiveValue / probe.effectiveValue, - ); - consider( - ctx.rateIsolated(probe, true).effectiveValue / probe.effectiveValue, - ); - } - } - return ceiling; -} - -/** - * Whether a composition target could still beat the layout in hand, which is what - * decides whether the retarget stage runs at all. - * - * `target.power` is the power a perfect arrangement of the target would reach at - * the PLAIN roster's figures and `bestPower` is what a rated layout actually - * scored, so the ceiling is what puts the two in the same units — see - * `islandRatingCeiling`. The caller's gate is a `break` over a descending list, - * so answering `false` once ends the stage: a named function so the reading can - * be pinned, since it is the difference between a stage that runs and one that is - * silently never entered. - */ -function targetCanBeat( - target: ScoredComposition, - bestPower: number, - ratingCeiling: number, -): boolean { - return target.power * ratingCeiling > bestPower + EPS; -} - -function sameFill(a: EffectiveBuilding[], b: EffectiveBuilding[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; - return true; -} - -function countById( - buildings: Iterable, -): Map { - const counts = new Map(); - for (const b of buildings) counts.set(b.id, (counts.get(b.id) ?? 0) + 1); - return counts; -} - -function countPlacement(placement: Placement): Map { - const counts = new Map(); - for (const b of placement) { - if (b !== null) counts.set(b.id, (counts.get(b.id) ?? 0) + 1); - } - return counts; -} - -function sameCounts(a: Map, b: Map): boolean { - if (a.size !== b.size) return false; - for (const [id, n] of a) if (b.get(id) !== n) return false; - return true; -} - -/** - * Places a fixed multiset of buildings, then improves the ARRANGEMENT only. - * - * Starting from `reference` (the best layout found so far), tiles are converted - * to match the target counts, choosing at each step the change that costs least - * power. The result is then improved with swaps, which move buildings around - * without changing what is on the board — so every candidate keeps the target's - * global heat/cooling budget and the search is over arrangements alone. - */ -async function arrangeComposition( - composition: EffectiveBuilding[], - reference: Placement, - ctx: IslandContext, - deadlineMs: number, - pacer: Pacer, -): Promise { - if (composition.length > ctx.n) return null; - - const wanted = countById(composition); - const byId = new Map(); - for (const b of composition) byId.set(b.id, b); - const current = reference.slice(); - - // 1. Reconcile counts with the target, cheapest change first. - while (performance.now() < deadlineMs && !pacer.stopRequested) { - const have = countPlacement(current); - const surplus = new Set(); - for (const [id, n] of have) { - if (n > (wanted.get(id) ?? 0)) surplus.add(id); - } - - let targetId: string | null = null; - for (const [id, n] of wanted) { - if (n > (have.get(id) ?? 0)) { - targetId = id; - break; - } - } - - let bestMove = -1; - let bestPower = -1.0; - - if (targetId === null) { - // Too many buildings overall: drop the least useful surplus tile. - if (surplus.size === 0) break; - for (let tile = 0; tile < ctx.n; tile++) { - const held = current[tile]; - if (held === null || !surplus.has(held.id)) continue; - current[tile] = null; - const power = simulateIsland(current, ctx).totalPower; - put(current, ctx, tile, held); - if (power > bestPower) { - bestPower = power; - bestMove = tile; - } - } - if (bestMove < 0) break; - current[bestMove] = null; - } else { - const building = byId.get(targetId)!; - for (let tile = 0; tile < ctx.n; tile++) { - if (current[tile] !== null) continue; - put(current, ctx, tile, building); - const power = simulateIsland(current, ctx).totalPower; - current[tile] = null; - if (power > bestPower) { - bestPower = power; - bestMove = tile; - } - } - if (bestMove < 0) { - for (let tile = 0; tile < ctx.n; tile++) { - const held = current[tile]; - if (held === null || !surplus.has(held.id)) continue; - put(current, ctx, tile, building); - const power = simulateIsland(current, ctx).totalPower; - put(current, ctx, tile, held); - if (power > bestPower) { - bestPower = power; - bestMove = tile; - } - } - } - if (bestMove < 0) return null; - put(current, ctx, bestMove, building); - } - - const now = performance.now(); - if (pacer.due(now)) await pacer.pump(now); - } - - if (!sameCounts(countPlacement(current), wanted)) return null; - - // 2. Improve the arrangement with swaps, which preserve the composition. - // Only nearby pairs are tried: swapping two buildings changes the layout - // solely through who they are adjacent to, so a swap between opposite ends of - // the island is two independent local changes and is better found as two - // separate moves. Restricting to Chebyshev distance 2 cuts the pairs to a - // fraction of all-pairs on a large island. - const occupied: number[] = []; - for (let tile = 0; tile < ctx.n; tile++) { - if (current[tile] !== null) occupied.push(tile); - } - // Plain ascending (x, y) order, which is NOT the tile index order - // (ascending X, DESCENDING Y). The fixtures pin this pairing exactly. - occupied.sort((a, b) => ctx.xs[a] - ctx.xs[b] || ctx.ys[a] - ctx.ys[b]); - - const pairs: [number, number][] = []; - for (let i = 0; i < occupied.length; i++) { - for (let j = i + 1; j < occupied.length; j++) { - if (chebyshev(ctx, occupied[i], occupied[j]) <= SWAP_RADIUS) { - pairs.push([occupied[i], occupied[j]]); - } - } - } - - while (performance.now() < deadlineMs && !pacer.stopRequested) { - const basePower = simulateIsland(current, ctx).totalPower; - let bestGain = 0.0; - let bestSwap: [number, number] | null = null; - - for (let index = 0; index < pairs.length; index++) { - if ((index & 63) === 0) { - const now = performance.now(); - if (now >= deadlineMs) break; - if (pacer.due(now)) { - await pacer.pump(now); - if (pacer.stopRequested) break; - } - } - - const [a, b] = pairs[index]; - const valA = current[a]; - const valB = current[b]; - // Swapping identical buildings changes nothing — asked in each tile's own - // units, so two scale classes holding the same roster entry still count - // as identical. See `ratedFor`. - if (ratedFor(ctx, a, valB) === valA && ratedFor(ctx, b, valA) === valB) { - continue; - } - - put(current, ctx, a, valB); - put(current, ctx, b, valA); - - const trial = simulateIsland(current, ctx); - if ( - trial.totalPower - basePower > bestGain && - !anyOfflineProducer(current, trial.placements) && - planHolds(current, ctx) - ) { - bestGain = trial.totalPower - basePower; - bestSwap = [a, b]; - } - - put(current, ctx, a, valA); - put(current, ctx, b, valB); - } - - if (bestSwap === null) break; - const [a, b] = bestSwap; - const held = current[a]; - put(current, ctx, a, current[b]); - put(current, ctx, b, held); - } - - return current; -} - -// --------------------------------------------------------------------------- -// Local repair and pruning -// --------------------------------------------------------------------------- - -/** - * The lean set of buildings worth trying on a tile during local repair. - * - * Generators and coolers jump 400x-4100x per tier, so only their top tier can - * ever appear in a good layout. Reactors only jump ~8x, which is small enough - * that a weaker one is genuinely useful for topping up a generator's spare heat - * capacity when there is leftover cooling, so a few reactor tiers are kept. - * Every extra candidate costs another full pass over the island. - */ -function polishCandidates( - reactors: EffectiveBuilding[], - generators: EffectiveBuilding[], - coolers: EffectiveBuilding[], - directProducers: EffectiveBuilding[], -): (EffectiveBuilding | null)[] { - const candidates: (EffectiveBuilding | null)[] = reactors.slice( - 0, - POLISH_REACTOR_TIERS, - ); - if (generators.length > 0) candidates.push(generators[0]); - if (coolers.length > 0) candidates.push(coolers[0]); - if (directProducers.length > 0) candidates.push(directProducers[0]); - // Leaving the tile empty is a real option. - candidates.push(null); - return candidates; -} - -/** - * Steepest-ascent repair: repeatedly applies the single-tile change that raises - * power the most, considering only stable layouts, until no single change - * helps. - * - * The annealing walk handles small islands well, but on a large, fully packed - * one nearly every single-tile change costs several percent, so the walk drifts - * downhill and rarely climbs back: measured on a 67-tile island, only 1 - * evaluation in 26,000 beat its own starting layout, while 20 stable improving - * moves sat a single move away. A full sweep here costs |tiles| x |candidates| - * simulations and finds them directly. - */ -async function greedyPolish( - placement: Placement, - ctx: IslandContext, - candidates: (EffectiveBuilding | null)[], - deadlineMs: number, - pacer: Pacer, -): Promise<{ placement: Placement; power: number }> { - let current = placement.slice(); - const sim = simulateIsland(current, ctx); - let bestPower = sim.totalPower; - if (anyOfflineProducer(current, sim.placements) || !planHolds(current, ctx)) { - const stabilized = stabilize(current, ctx); - current = stabilized.placement; - bestPower = stabilized.power; - } - - // The gain of every (tile, candidate) change, measured against the layout - // as it stood when that tile was last swept. A full sweep after every - // accepted move was the whole cost of this stage — |tiles| x |candidates| - // simulations per move, 2-3s for one repair on the largest shipped islands - // — and nearly all of it re-measured changes far from the move, whose gain - // the move cannot have touched: a single-tile change is felt through its - // neighbours' supply and demand, so only tiles within `POLISH_DIRTY_RADIUS` - // of an applied move are swept again. The rest keep their figure, which is - // approximate once anything nearby has moved, so a cached winner is always - // re-simulated before it is applied and dropped back into the sweep if it - // no longer holds. Every applied change is therefore verified exactly; - // only the order the changes are found in can differ from a full sweep. - // - // Measured with the survey (`--runs 3 --seed 1`, every map and anomaly): - // at 15s per map it is a wash, every map inside the noise band; at 5s — - // where the polish share binds, as it does on a phone at any budget — Tidal - // gains 5-9% on six of the eight maps and Shadowspire under Singularity - // 16%, against one 83-tile island down 0.7%. One repair on the seed of the - // largest island went from 950ms to 180ms (`npm run profile`). - const nCandidates = candidates.length; - const gains = new Float64Array(ctx.n * nCandidates).fill(-Infinity); - const dirty = new Uint8Array(ctx.n).fill(1); - - while (performance.now() < deadlineMs && !pacer.stopRequested) { - let cut = false; - for (let tile = 0; tile < ctx.n; tile++) { - if (dirty[tile] === 0) continue; - const now = performance.now(); - if (now >= deadlineMs) { - cut = true; - break; - } - if (pacer.due(now)) { - await pacer.pump(now, () => islandProgress(current, ctx)); - if (pacer.stopRequested) { - cut = true; - break; - } - } - - dirty[tile] = 0; - const held = current[tile]; - for (let k = 0; k < nCandidates; k++) { - const building = candidates[k]; - let gain = -Infinity; - // The candidate list is the plain roster, `held` is rated for this tile: - // asked the wrong way round, a scaled tile re-simulates its own layout - // once per sweep for nothing. See `ratedFor`. - if (ratedFor(ctx, tile, building) !== held) { - put(current, ctx, tile, building); - const trial = simulateIsland(current, ctx); - if ( - trial.totalPower - bestPower > 0 && - !anyOfflineProducer(current, trial.placements) && - planHolds(current, ctx) - ) { - gain = trial.totalPower - bestPower; - } - put(current, ctx, tile, held); - } - gains[tile * nCandidates + k] = gain; - } - } - if (cut) break; - - let bestGain = 0.0; - let bestTile = -1; - let bestCandidate = -1; - for (let i = 0; i < gains.length; i++) { - if (gains[i] > bestGain) { - bestGain = gains[i]; - bestTile = (i / nCandidates) | 0; - bestCandidate = i - bestTile * nCandidates; - } - } - if (bestTile < 0) break; - - // Exact, whether the figure was fresh or cached: the one simulation that - // decides what this stage hands on. - const held = current[bestTile]; - put(current, ctx, bestTile, candidates[bestCandidate]); - const trial = simulateIsland(current, ctx); - if ( - trial.totalPower - bestPower > 0 && - !anyOfflineProducer(current, trial.placements) && - planHolds(current, ctx) - ) { - bestPower = trial.totalPower; - for (let tile = 0; tile < ctx.n; tile++) { - if (chebyshev(ctx, tile, bestTile) <= POLISH_DIRTY_RADIUS) - dirty[tile] = 1; - } - } else { - // Stale: the layout moved under it. Back to the sweep for a fresh figure. - put(current, ctx, bestTile, held); - dirty[bestTile] = 1; - } - } - - return { placement: current, power: bestPower }; -} - -/** - * Post-search cleanup: - * 1. Drops every producer generating 0 power, repeating until none remain. - * 2. Sequentially tests removing reactors/coolers to eliminate redundant - * support structures. - * 3. Guarantees a stable layout — every returned producer is online — with a - * minimal active tile footprint. - * - * Step 1 is unconditional: an overheating building is never acceptable, even - * when keeping it would score higher (see `stabilize`). Step 2 is guarded, - * since removing support can only ever cost power. - */ -function pruneDeadWeight( - placement: Placement, - ctx: IslandContext, -): { rows: SimPlacedBuilding[]; power: number } { - let anyOccupied = false; - for (let tile = 0; tile < ctx.n; tile++) { - if (placement[tile] !== null) { - anyOccupied = true; - break; - } - } - if (!anyOccupied) return { rows: [], power: 0.0 }; - - // 1. Remove producers that generate no power (cascading). - const stabilized = stabilize(placement, ctx); - const current = stabilized.placement; - let bestPower = stabilized.power; - let rows = stabilized.rows; - - // 2. Greedy backward sweep: test removing support buildings (reactors & coolers). - const support: number[] = []; - for (let tile = 0; tile < ctx.n; tile++) { - const b = current[tile]; - if (b === null) continue; - if (b.type === "cooler" || isReactor(b)) support.push(tile); - } - - for (const tile of support) { - const held = current[tile]; - if (held === null) continue; - - current[tile] = null; - const trial = simulateIsland(current, ctx); - - // Power alone is not enough to accept a removal. Dropping a reactor can - // starve one generator to zero while handing its heat to another that had - // spare capacity, leaving total power unchanged but stranding an idle - // generator on the board. - if ( - trial.totalPower >= bestPower - EPS && - !anyOfflineProducer(current, trial.placements) && - planHolds(current, ctx) - ) { - bestPower = trial.totalPower; - rows = trial.placements; - } else { - put(current, ctx, tile, held); - } - } - - return { rows, power: bestPower }; -} - -// --------------------------------------------------------------------------- -// Right-sizing the finished layout -// --------------------------------------------------------------------------- - -/** - * The roles a finished layout can be re-tiered in. A direct producer always - * runs flat out on its own, so there is no slack in one to give back — a - * smaller tier is simply less power, and the power guard below would reject it - * anyway. The other three are sized against a load the finished layout fixes, - * and any capacity above that load is money spent on a building that never - * uses it. - */ -const DOWNGRADE_ROLES: ReadonlySet = new Set([ - "cooler", - "reactor", - "generator", -]); +import { AlternateCollector, isDistinctLayout, powerTies } from "./alternates"; +import { EPS } from "./constants"; +import { buildIslandContext, type IslandContext } from "./context"; +import { Pacer } from "./pacer"; +import { randomSeed, Rng } from "./rng"; +import type { + AnomalyDefinition, + EffectiveBuilding, + IslandLayout, + IslandSubGrid, + PlacedBuilding, + SearchHooks, +} from "./types"; +import { hillClimb } from "./search/anneal"; +import { + arrangeComposition, + generatorCapacityTable, + islandRatingCeiling, + targetCanBeat, + targetCompositions, +} from "./search/composition"; +import { + emptyPlacement, + isDirectProducer, + isReactor, + put, + toPlacedBuildings, +} from "./search/placement"; +import { + EVACUATE_BUDGET_MS, + GATHER_BUDGET_MS, + gatherPooledCoolers, +} from "./search/pooledCoolers"; +import { + greedyPolish, + polishCandidates, + pruneDeadWeight, +} from "./search/repair"; +import { + DOWNGRADE_ROLES, + downgradeOversized, + downgradeTiers, + tileLoad, +} from "./search/rightSize"; +import { constructMultiStartSeed } from "./search/seed"; +import { + isUnderFed, + offlineProducers, + stabilize, + upgradePlans, +} from "./search/stability"; -/** - * True when `capacity` meets `load` at the game's own relative tolerance. - * - * This is `wasteIsCovered`, whose parameters are named for the cooling case it - * was written for; the tolerance has to be relative here for the same reason it - * does there — at 1e18 one ULP is already ~1e2, so an absolute epsilon would - * reject a tier that is exactly big enough. - */ -function covers(capacity: number, load: number): boolean { - return wasteIsCovered(load, capacity); -} +// The stages live in `./search/`, one file each; this module is still the one +// entry point, so what it exported before the split it exports now. +export { downgradeOversized } from "./search/rightSize"; /** - * What a placed building is actually doing, in the units its tier is sized in. + * Placement search for a single island: * - * Cooling sent for a cooler, heat sent for a reactor, heat absorbed for a - * generator — the figure a replacement tier has to be able to match for the - * rest of the layout to keep behaving the same way. - */ -function tileLoad(building: EffectiveBuilding, row: SimPlacedBuilding): number { - if (building.type === "cooler") return row.coolingProvided; - if (isReactor(building)) return row.heatProduced; - return row.heatConsumed; -} - -/** - * The capacity `candidate` would actually run at on `tile` in this layout — - * `tileLoad`'s units, so the two can be compared. + * seed -> repair -> composition retarget -> (annealing -> repair)* -> pruning + * -> right-sizing * - * `ctx.rate` answers this for every rule but one: a role isolation multiplier is - * a function of what a tile's NEIGHBOURS are, so `rate` is the identity under it - * and `simulateIsland` resolves it per layout through `rateIsolated` instead. - * That leaves right-sizing measuring a load rated by `isolated` against an - * unrated capacity: an isolated generator authored 320, rated 1280 (at the - * `isolated` the rule shipped with) and absorbing 300, was never offered the - * authored 120 tier that covers it at its own rating of 480, so the pass left - * 980 of intake nobody pays it to have — which is exactly the money it - * exists to hand back. In the other direction the plain figure is over-generous, - * and there the re-simulation below catches it, so only the waste escaped. + * The `annealing -> repair` pair repeats until the deadline. Repair's slice is + * reserved before the layout it will run on exists, and on a post-anneal layout + * it almost always converges in one fruitless sweep, so whatever it hands back + * becomes another short walk rather than idle time at the end of the budget. * - * The neighbour scan mirrors `simulateIsland`'s: it reads what a neighbour *is*, - * which the swap cannot change — the ladder is one role deep, so a re-tiered - * generator is still a generator and every tile's crowding survives the pass. - */ -function ratedCapacity( - ctx: IslandContext, - placement: Placement, - tile: number, - candidate: EffectiveBuilding, -): number { - const isolation = ctx.isolation; - if (isolation === null || candidate.type !== isolation.role) { - return candidate.effectiveValue; - } - - const neighbors = ctx.neighbors[tile]; - let crowded = false; - for (let k = 0; k < neighbors.length; k++) { - const other = placement[neighbors[k]]; - if (other !== null && other.type === isolation.role) { - crowded = true; - break; - } - } - return ctx.rateIsolated(candidate, crowded).effectiveValue; -} - -/** Each downgradable role's roster entries, smallest capacity first. */ -function downgradeTiers( - effectiveBuildings: EffectiveBuilding[], -): Map { - const tiers = new Map(); - for (const role of DOWNGRADE_ROLES) { - tiers.set( - role, - effectiveBuildings - .filter((b) => b.type === role) - .sort((a, b) => a.effectiveValue - b.effectiveValue), - ); - } - return tiers; -} - -/** - * Final pass: replace every building with the smallest tier that still does the - * job the finished layout gives it. Port of `_downgrade_oversized`. + * Seed construction lays out self-sufficient "hubs" — one generator plus + * exactly the reactors and coolers it needs on its own. That is a good starting + * shape but a systematically incomplete one: real layouts do better by letting + * neighbouring hubs SHARE, one cooler absorbing waste from two generators or + * one reactor feeding two, which frees tiles for more producers. Nothing in + * seed construction can express that, and on the 67-tile map 1 island it caps + * out 7% below the best known layout. * - * The search maximizes power, and power cannot tell a cooler running flat out - * from one running at 2%: both keep the same generators online, so both score - * identically and the search has no reason to prefer either. It therefore - * leaves the roster's top tier on tiles that need a twentieth of it, which - * costs the player real money for capacity that never runs. This is why the - * pass belongs after the search rather than inside it — only once the layout - * stops moving is the load on each tile settled enough to size against. + * The two stages around it exist to cover that blind spot from different sides: * - * Covering the tile's current load is what makes a candidate plausible, not - * what makes it safe: shrinking a supplier changes the FairShare split, and the - * split is what decides which producers clear their cooling. So every swap is - * re-simulated and accepted only under the test `pruneDeadWeight` applies to a - * removal — no power lost against the layout handed in, and no producer left - * offline. Power is defended against the figure this pass started from rather - * than against the running total, so a sequence of swaps cannot drift down one - * tolerance at a time. + * - `greedyPolish` is steepest ascent over single-tile changes. Annealing alone + * was supposed to find these, and does on small islands, but on a large + * packed island almost every single-tile change costs several percent, so the + * walk drifts downhill and rarely climbs back — measured there, only 1 + * evaluation in 26,000 beat its own starting layout while 20 stable improving + * moves sat a single move away. + * - `targetCompositions` / `arrangeComposition` attack the other half. Deciding + * WHAT to build is a counting problem with an exact answer, so it is computed + * directly rather than searched for; the search is then left with the + * genuinely hard part, which is where to put it. * - * The sweep repeats to a fixpoint: downgrading a generator changes the waste - * its cooler has to absorb, which can free a cooler this sweep already walked - * past. It terminates because every accepted swap strictly lowers one tile's - * capacity and the ladders are finite. + * Annealing keeps its place between them for the moves neither can make: it can + * cross a barrier that no single change improves upon. * - * Tiles are visited in ascending index order, which is the game's own spatial - * order — see `context.ts`. - */ -export function downgradeOversized( - rows: SimPlacedBuilding[], - power: number, - effectiveBuildings: EffectiveBuilding[], - ctx: IslandContext, -): { rows: SimPlacedBuilding[]; power: number } { - if (rows.length === 0) return { rows, power }; - - const byId = new Map(effectiveBuildings.map((b) => [b.id, b])); - const current = emptyPlacement(ctx.n); - const occupied: number[] = []; - for (const row of rows) { - const building = byId.get(row.buildingId); - // Everything the search places comes from this roster; a layout holding - // anything else is not one this pass can reason about. - if (building === undefined) return { rows, power }; - put(current, ctx, row.idx, building); - occupied.push(row.idx); - } - occupied.sort((a, b) => a - b); - - const tiers = downgradeTiers(effectiveBuildings); - const rowAt = new Map(rows.map((r) => [r.idx, r])); - const floor = power; - - for (let changed = true; changed;) { - changed = false; - - for (const tile of occupied) { - const building = current[tile]!; - const ladder = tiers.get(building.type); - if (ladder === undefined || ladder.length === 0) continue; - - const load = tileLoad(building, rowAt.get(tile)!); - - for (const candidate of ladder) { - // Rated for this tile before it is compared to anything: `building` is - // what the tile is running and `load` is what the layout measured it - // doing, both in this tile's units, and the ladder is the plain roster. - // Ascending order survives the rating, since one tile scales every - // candidate by the same factor. - const rated = ctx.rate(tile, candidate); - // Ascending, so nothing smaller is left to try. Both sides are `rate`'s - // units, and one tile scales every candidate by the same factor, so the - // ladder's order survives whichever units this is asked in. - if (rated.effectiveValue >= building.effectiveValue) break; - // Covering the load is asked in the layout's units, which is `rate`'s - // plus whatever `simulateIsland` resolves per layout — see - // `ratedCapacity`. `load` came off a row it measured, so this is the one - // comparison here that cannot be made in the roster's units. - if (!covers(ratedCapacity(ctx, current, tile, rated), load)) continue; - - put(current, ctx, tile, rated); - const trial = simulateIsland(current, ctx); - - if ( - covers(trial.totalPower, floor) && - !anyOfflineProducer(current, trial.placements) && - planHolds(current, ctx) - ) { - power = trial.totalPower; - rows = trial.placements; - rowAt.clear(); - for (const r of rows) rowAt.set(r.idx, r); - changed = true; - break; - } - - put(current, ctx, tile, building); - } - } - } - - return { rows, power }; -} - -/** - * How long `gatherPooledCoolers` may spend, per solve on the primary layout and - * once more shared across its alternates. It runs after the search's deadline, - * so this is time added to a run. Moving a cooler onto an empty tile costs - * nothing. The cap is for the cooler-to-producer swaps, which mostly fail and - * cost a simulation each. On the shipped maps one layout finishes in 15-110ms. - */ -const GATHER_BUDGET_MS = 150; - -/** - * How long `evacuateMinorLandmasses` may spend, on top of `GATHER_BUDGET_MS` - * and taken first. Spent only on a board with producers on more than one - * landmass, and the pass stops as soon as a hub is placed — moving a - * 15-producer hub costs about 170ms on a desktop. The cap is sized for a phone, - * several times slower, where a tight one would make the pass silently do - * nothing; a second is small beside a 10-30s attempt. Out of time, the layout - * stays as it was. - */ -const EVACUATE_BUDGET_MS = 1000; - -/** Chebyshev radius of the patch `gatherPooledCoolers` anchors on. */ -const GATHER_ANCHOR_RADIUS = 2; - -/** Each tile's landmass, numbered from 0, and how many there are. */ -function landmasses(ctx: IslandContext): { - component: Int32Array; - count: number; -} { - const component = new Int32Array(ctx.n).fill(-1); - let count = 0; - for (let start = 0; start < ctx.n; start++) { - if (component[start] >= 0) continue; - const id = count++; - const stack = [start]; - component[start] = id; - while (stack.length > 0) { - const nb = ctx.neighbors[stack.pop()!]; - for (let k = 0; k < nb.length; k++) { - if (component[nb[k]] < 0) { - component[nb[k]] = id; - stack.push(nb[k]); - } - } - } - } - return { component, count }; -} - -/** - * Under a shared cooling pool, moves the hubs off every landmass but the main - * one, so that landmass can be given to the coolers. + * Right-sizing is the odd one out: it is not part of the search and cannot find + * a better layout. It runs once the layout is final and swaps each building for + * the smallest tier that still carries the load that layout gives it, because + * power is blind to the difference between a cooler running flat out and one + * running at 2% — and the player is not. * - * `gatherPooledCoolers` moves one tile at a time, and that cannot move a hub: - * a generator taken from its reactors loses power at every step of the way, so - * the hub on a small second landmass stays there and its coolers stay with it. - * This moves a landmass's producers all at once instead. Each takes a tile on - * the main landmass, the one holding the most producers, and the cooler it - * displaces goes to one of the tiles the producers left — so the pool's total - * and the board's composition never change, and the landmass comes out - * producer-free for the sweep to fill. + * **The stability rule.** A solved layout must be fully stable: every generator + * and direct producer it places has to actually run. A layout can score higher + * by parking reactor heat in a generator that is never cooled — nothing then + * has to cool that share of the waste — and it is still the wrong answer, so + * only stable layouts are ever recorded as best. "Pruning never reduces power" + * is NOT a valid invariant here; "the returned layout is stable" is. * - * The producers are placed greedily, whichever of them is worth the most power - * on whichever free tile goes next, and then the main landmass is swapped - * into shape until power is back (`relocateHub`). A landmass is evacuated only if the result - * holds the power floor, keeps every producer online and passes the upgrade - * plan; otherwise the layout is left exactly as it was. Smallest first, since a - * hub of a few producers is the likeliest to fit. + * **Under an upgrade plan "stable" means stable at every step.** The search + * scores at the plan's target, but every gate that admits a layout — the best + * record, repair, the composition swaps, pruning, right-sizing, the tie + * shortlist and `stabilize` itself — also asks `planHolds`, so a layout that + * overheats at any tier on the way is never kept. Without a plan that call + * returns at once and nothing here changes. */ -function evacuateMinorLandmasses( - current: Placement, - ctx: IslandContext, - component: Int32Array, - count: number, - floor: number, - deadlineMs: number, -): void { - const producersOn = new Array(count).fill(0); - for (let t = 0; t < ctx.n; t++) { - const b = current[t]; - if (b !== null && b.type !== "cooler") producersOn[component[t]]++; - } - let main = 0; - for (let c = 1; c < count; c++) - if (producersOn[c] > producersOn[main]) main = c; - - const minors: number[] = []; - for (let c = 0; c < count; c++) - if (c !== main && producersOn[c] > 0) minors.push(c); - minors.sort((a, b) => producersOn[a] - producersOn[b] || a - b); - - const trial = current.slice(); - for (const minor of minors) { - if (performance.now() >= deadlineMs) return; - copyInto(trial, current); - if (relocateHub(trial, ctx, component, minor, main, floor, deadlineMs)) - copyInto(current, trial); - } -} +/** Share of the time budget reserved for repair before and after annealing. */ +const POLISH_SHARE = 0.3; /** - * Moves every producer on `minor` onto `main`, in place. True when the result - * is a layout `evacuateMinorLandmasses` may keep; `trial` is junk otherwise. + * The least time the plain-sized seed's repair is given under a role + * isolation, when the sized seed's own repair took less (see `solveIsland`). + * A 20-tile island repairs in 3ms, and a cap that tight would be timer noise. */ -function relocateHub( - trial: Placement, - ctx: IslandContext, - component: Int32Array, - minor: number, - main: number, - floor: number, - deadlineMs: number, -): boolean { - const movers: EffectiveBuilding[] = []; - const vacated: number[] = []; - for (let t = 0; t < ctx.n; t++) { - const b = trial[t]; - if (component[t] !== minor || b === null || b.type === "cooler") continue; - movers.push(b); - vacated.push(t); - trial[t] = null; - } - - // A tile on the main landmass a producer may take: empty, or a cooler that - // can step across to the landmass being emptied. - const free = (t: number): boolean => - component[t] === main && (trial[t] === null || trial[t]!.type === "cooler"); - const freeAround = (t: number): number => { - let n = 0; - for (const u of ctx.neighbors[t]) if (free(u)) n++; - return n; - }; - // A reactor makes power only through a generator beside it, so a tile with - // none is not worth simulating. - const besideGenerator = (t: number): boolean => { - for (const u of ctx.neighbors[t]) - if (trial[u] !== null && trial[u]!.type === "generator") return true; - return false; - }; - - let nextVacated = 0; - while (movers.length > 0) { - if (performance.now() >= deadlineMs) return false; - - let bestPower = -Infinity; - let bestRoom = -1; - let bestTile = -1; - let bestMover = -1; - // A reactor beside no generator is still placed when nothing better is - // left — a generator that took the strongest tile may have taken the last - // free one beside it too — and the swap descent below moves it into place. - for (const anywhere of [false, true]) { - const tried = new Set(); - for (let m = 0; m < movers.length; m++) { - const b = movers[m]; - if (tried.has(b.id)) continue; - tried.add(b.id); - for (const t of ctx.tiles) { - if (!free(t)) continue; - if (!anywhere && isReactor(b) && !besideGenerator(t)) continue; - const held = trial[t]; - put(trial, ctx, t, b); - const power = simulateIsland(trial, ctx).totalPower; - trial[t] = held; - // On a tie, the tile with the most room left around it, so a - // generator lands where its reactors can follow. - const room = freeAround(t); - if (power > bestPower || (power === bestPower && room > bestRoom)) { - bestPower = power; - bestRoom = room; - bestTile = t; - bestMover = m; - } - } - } - if (bestTile >= 0) break; - } - if (bestTile < 0) return false; - - const displaced = trial[bestTile]; - put(trial, ctx, bestTile, movers[bestMover]); - // The displaced cooler crosses over at once, so the pool is whole while - // the rest of the hub is placed. - if (displaced !== null) put(trial, ctx, vacated[nextVacated++], displaced); - movers.splice(bestMover, 1); - } - - // The greedy order cannot see that a reactor placed early sits on the tile a - // later generator wanted, and the hubs already on the main landmass were - // built around a board that had no room for these. So: a swap descent over - // the whole landmass, nearby pairs only (as `arrangeComposition` does), first - // gain taken, until the floor is met. Swaps never change what is on the - // board, and the emptied landmass is not in the pairs, so nothing returns to - // it. On a cleared Entropy Isles under Cryo the greedy reaches 98.5% of the - // floor and one pass of this closes the rest. - const onMain: number[] = []; - for (const t of ctx.tiles) if (component[t] === main) onMain.push(t); - const pairs: [number, number][] = []; - for (let i = 0; i < onMain.length; i++) - for (let j = i + 1; j < onMain.length; j++) - if (chebyshev(ctx, onMain[i], onMain[j]) <= SWAP_RADIUS) - pairs.push([onMain[i], onMain[j]]); - - const producer = (b: EffectiveBuilding | null): boolean => - b !== null && b.type !== "cooler"; - let power = simulateIsland(trial, ctx).totalPower; - let improved = true; - while (improved && !covers(power, floor)) { - improved = false; - for (const [a, b] of pairs) { - if (performance.now() >= deadlineMs) return false; - const va = trial[a]; - const vb = trial[b]; - // Coolers and empty tiles are interchangeable under the pool. - if (!producer(va) && !producer(vb)) continue; - if (ratedFor(ctx, a, vb) === va) continue; - put(trial, ctx, a, vb); - put(trial, ctx, b, va); - const swapped = simulateIsland(trial, ctx).totalPower; - if (swapped > power) { - power = swapped; - improved = true; - if (covers(power, floor)) break; - continue; - } - put(trial, ctx, a, va); - put(trial, ctx, b, vb); - } - } +const PLAIN_SEED_POLISH_MIN_MS = 20; - const final = simulateIsland(trial, ctx); - return ( - covers(final.totalPower, floor) && - !anyOfflineProducer(trial, final.placements) && - planHolds(trial, ctx) - ); -} +/** Share of the time budget spent arranging those compositions. */ +const COMPOSITION_SHARE = 0.2; /** - * Under a shared cooling pool, pulls the coolers into one area of the board - * and onto the small islands no producer is using. - * - * Under the pool a cooler's tile does not matter. Every cooler pays into one - * figure (`simulateIsland`), so power cannot tell a cooler in the middle of the - * hubs from one at the edge. The walk leaves coolers wherever a hub left a gap, - * scattered across the landmass, and that board is harder to read and to build - * than the same layout with its coolers together. - * - * Like `downgradeOversized`, this cannot find a better layout. It only moves - * coolers, swapping each with a tile nearer the anchor. Moving onto an empty - * tile always holds, because the pool's total and every producer are - * untouched. A swap with a producer moves that producer into the cooler's old - * tile, so it is re-simulated and kept only if power holds, every producer - * stays online and the upgrade plan still runs. - * - * The anchor is the tile with the most coolers within `GATHER_ANCHOR_RADIUS`, - * so the coolers gather on the patch where most of them already stand. Anchoring - * on the coolers' centroid was measured too: it put the anchor among the hubs, - * where a swap rarely holds, and left more clusters on maps 1 and 8. A tile on a - * landmass with no producer is a scrap nothing else can use, and counts as - * nearer than any tile on a working landmass, so empty scraps fill first. Every - * accepted move strictly lowers the coolers' summed distance, so the sweep - * terminates; the deadline only bounds how far it gets. + * The closing repair is reserved a share of the budget up front, then handed + * back whatever it does not use (see `solveIsland`). These bound the handback: + * the repair is left twice its measured cost as headroom, but never more than + * half of what is left — on a big island one sweep costs seconds, and reserving + * twice that would strand more time than it protects. A reclaimed round shorter + * than the floor is not worth its own setup. */ -function gatherPooledCoolers( - rows: SimPlacedBuilding[], - power: number, - effectiveBuildings: EffectiveBuilding[], - ctx: IslandContext, - deadlineMs: number, - evacuateDeadlineMs = Math.min( - deadlineMs, - performance.now() + EVACUATE_BUDGET_MS, - ), -): { rows: SimPlacedBuilding[]; power: number } { - if (ctx.anomaly.rule !== "shared_cooling" || rows.length === 0) - return { rows, power }; - - const byId = new Map(effectiveBuildings.map((b) => [b.id, b])); - const current = emptyPlacement(ctx.n); - for (const row of rows) { - const building = byId.get(row.buildingId); - if (building === undefined) return { rows, power }; - put(current, ctx, row.idx, building); - } - - const { component, count } = landmasses(ctx); - // The callers' deadline covers both budgets; capping the evacuation at its - // own (the default) leaves the sweep at least `GATHER_BUDGET_MS`. - evacuateMinorLandmasses( - current, - ctx, - component, - count, - power, - evacuateDeadlineMs, - ); - - // Which landmasses hold a producer, read after the evacuation so a landmass - // it emptied fills with coolers first, like any other scrap. - const working = new Array(count).fill(false); - for (let t = 0; t < ctx.n; t++) { - const b = current[t]; - if (b !== null && b.type !== "cooler") working[component[t]] = true; - } - - const movable = (t: number): boolean => { - const b = current[t]; - return b !== null && b.type === "cooler" && working[component[t]]; - }; - const placed: number[] = []; - for (let t = 0; t < ctx.n; t++) if (movable(t)) placed.push(t); - if (placed.length === 0) return { rows, power }; - - let anchor = -1; - let densest = -1; - for (let t = 0; t < ctx.n; t++) { - if (!working[component[t]]) continue; - let near = 0; - for (const u of placed) - if (chebyshev(ctx, t, u) <= GATHER_ANCHOR_RADIUS) near++; - if (near > densest) { - densest = near; - anchor = t; - } - } - - /** Squared distance to the anchor; a scrap tile is nearer than any. */ - const distance = new Float64Array(ctx.n); - for (let t = 0; t < ctx.n; t++) { - distance[t] = working[component[t]] - ? (ctx.xs[t] - ctx.xs[anchor]) ** 2 + (ctx.ys[t] - ctx.ys[anchor]) ** 2 - : -1; - } - // Nearest first, ascending index on a tie, so the result depends only on the - // board. - const byDistance = Array.from(ctx.tiles).sort( - (a, b) => distance[a] - distance[b] || a - b, - ); - - const floor = power; - let changed = true; - while (changed && performance.now() < deadlineMs) { - changed = false; - // Farthest cooler first: it has the most to gain from a move. - const coolers: number[] = []; - for (let t = 0; t < ctx.n; t++) if (movable(t)) coolers.push(t); - coolers.sort((a, b) => distance[b] - distance[a] || a - b); - - for (const from of coolers) { - if (performance.now() >= deadlineMs) break; - const cooler = current[from]!; - for (const to of byDistance) { - if (distance[to] >= distance[from]) break; - const other = current[to]; - if (other !== null && other.type === "cooler") continue; - - put(current, ctx, to, cooler); - put(current, ctx, from, other); - if (other === null) { - // The pool's total and every producer are unchanged. - changed = true; - break; - } - const trial = simulateIsland(current, ctx); - if ( - covers(trial.totalPower, floor) && - !anyOfflineProducer(current, trial.placements) && - planHolds(current, ctx) - ) { - changed = true; - break; - } - put(current, ctx, from, cooler); - put(current, ctx, to, other); - if (performance.now() >= deadlineMs) break; - } - } - } +const RECLAIM_POLISH_MARGIN = 2.0; +const RECLAIM_MAX_RESERVE_FRACTION = 0.5; +const RECLAIM_MIN_ROUND_SHARE = 0.01; +const RECLAIM_MIN_ROUND_MS = 50; - const final = simulateIsland(current, ctx); - // A move to an empty tile is exact, and a swap had to hold the floor, so a - // shortfall here would be a bug rather than a trade. Give the input back. - if (!covers(final.totalPower, floor)) return { rows, power }; - return { rows: final.placements, power: final.totalPower }; +export interface IslandSolution extends IslandLayout { + /** + * Other layouts this island's search finished tied with, best-power-first + * and never including the primary. Empty when the search found no second + * arrangement at the same power. See `alternates.ts` — they are gathered by + * watching the walk, and every one of them is stable and pruned exactly as + * the primary is. + */ + alternates?: IslandLayout[]; } /** @@ -2908,30 +220,6 @@ async function finalizeAlternates( return out; } -function islandProgress( - placement: Placement, - ctx: IslandContext, -): IslandProgress { - const pruned = pruneDeadWeight(placement, ctx); - return { - placements: toPlacedBuildings(pruned.rows), - powerOutput: pruned.power, - }; -} - -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - -/** - * Solves one island. - * - * `rngSeed` fixes the random stream of the annealing walk. Same seed, same move - * sequence — but stage deadlines are wall-clock, so where each stage stops (and - * the temperature at a given step) still varies slightly between runs; seeding - * narrows run-to-run variance rather than eliminating it. Omitting it draws a - * fresh seed, preserving the stochastic default. - */ /** * Deterministic replay of `solveIsland`'s reproducible stages, for the golden * fixture harness. Not used in production. @@ -3016,6 +304,16 @@ export async function replayIslandDeterministic( }; } +/** + * Solves one island. + * + * `rngSeed` fixes the random stream of the annealing walk. Same seed, same move + * sequence — but stage deadlines are wall-clock, so where each stage stops (and + * the temperature at a given step) still varies slightly between runs; seeding + * narrows run-to-run variance rather than eliminating it. Omitting it draws a + * fresh seed, preserving the stochastic default. + */ + export async function solveIsland( island: IslandSubGrid, effectiveBuildings: EffectiveBuilding[], diff --git a/packages/solver/src/solver/search/anneal.ts b/packages/solver/src/solver/search/anneal.ts new file mode 100644 index 0000000..1dc5a68 --- /dev/null +++ b/packages/solver/src/solver/search/anneal.ts @@ -0,0 +1,324 @@ +import { AlternateCollector, powerTies } from "../alternates"; +import { EPS } from "../constants"; +import type { IslandContext } from "../context"; +import { Pacer } from "../pacer"; +import { generatorEnergyRatio, wasteIsCovered } from "../physics"; +import { Rng } from "../rng"; +import { type SimPlacedBuilding, simulateIsland } from "../simulate"; +import type { EffectiveBuilding, Placement } from "../types"; +import { copyInto, put, ratedFor } from "./placement"; +import { islandProgress } from "./repair"; +import { + anyOfflineProducer, + isUnderFed, + planHolds, + stabilize, +} from "./stability"; + +/** + * The annealing walk: the one stage that can cross a barrier no single change + * improves upon. + */ + +function randomCandidate( + pools: EffectiveBuilding[][], + rng: Rng, +): EffectiveBuilding | null { + if (pools.length === 0 || rng.random() < 0.15) return null; + const pool = rng.choice(pools); + if (pool.length === 1 || rng.random() < 0.7) return pool[0]; + return rng.choice(pool); +} + +/** + * `maxSteps` caps the walk by iteration count and, when set, drives the + * temperature schedule by `step / maxSteps` instead of elapsed time. With a + * fixed `Rng` and a `timeBudgetMs` generous enough that the deadline never + * fires first, the walk is then fully deterministic: same seed, same result, + * bit for bit. That is what the golden fixtures stand on. The solve pipeline + * itself never sets it — wall time is the right budget in production, where + * per-step cost varies by island. + */ +export async function hillClimb( + seed: Placement, + ctx: IslandContext, + reactors: EffectiveBuilding[], + generators: EffectiveBuilding[], + coolers: EffectiveBuilding[], + directProducers: EffectiveBuilding[], + timeBudgetMs: number, + rng: Rng, + pacer: Pacer, + maxSteps?: number, + collector?: AlternateCollector, +): Promise { + if (timeBudgetMs <= 0 || ctx.n === 0) return seed; + + const current = seed.slice(); + let sim = simulateIsland(current, ctx); + let currentPower = sim.totalPower; + let currentPlacements = sim.placements; + + // The walk itself is free to pass through layouts containing overheating + // buildings — they are often one move away from good ones — but only a STABLE + // layout may be recorded as the best. Raw power alone would happily settle on + // a layout that parks heat in a permanently offline generator, which then has + // to be dismantled at the end, ending up worse than the best stable layout + // the search already walked past. + const initial = stabilize(current, ctx); + let bestPlacement = initial.placement; + let bestPower = initial.power; + // The layout the walk starts from is itself an answer at this power, and the + // shortlist has to hold one before it can recognise a tie with it. + collector?.offer(initial.power, initial.placement); + // Highest raw score seen so far, used only to decide when an unstable layout + // is promising enough to be worth stabilizing and re-scoring. + let bestRawPower = currentPower; + let stall = 0; + + const recordIfBest = ( + power: number, + snapshot: Placement, + rows: SimPlacedBuilding[], + ): void => { + if (power <= bestPower + EPS) { + // Not an improvement — but a layout that *ties* the best is a second + // answer worth offering the user, so it goes on the shortlist. The + // checks are ordered by what they cost: `full` is a size comparison, + // `accepts` reads the layout's tiles, and only then does the stability + // test walk every row. A converged walk ties its best constantly and + // nearly all of those are near copies of a layout already held, so + // `accepts` is what keeps this off the hot path once the shortlist has + // stopped growing. + if ( + collector !== undefined && + !collector.full && + powerTies(power, collector.power) && + collector.accepts(snapshot) && + !anyOfflineProducer(snapshot, rows) && + planHolds(snapshot, ctx) + ) { + collector.offer(power, snapshot); + } + return; + } + + if (!anyOfflineProducer(snapshot, rows) && planHolds(snapshot, ctx)) { + bestPower = power; + bestPlacement = snapshot.slice(); + collector?.offer(power, snapshot); + stall = 0; + return; + } + + // Unstable. Only worth the extra simulations when it is a new raw + // high-water mark — otherwise the walk would re-stabilize the same + // unreachable peak on every visit. + if (power > bestRawPower + EPS) { + bestRawPower = power; + const stabilized = stabilize(snapshot, ctx); + if (stabilized.power > bestPower + EPS) { + bestPower = stabilized.power; + bestPlacement = stabilized.placement; + collector?.offer(stabilized.power, stabilized.placement); + stall = 0; + } + } + }; + + const topCooler = coolers.length > 0 ? coolers[0] : null; + const topGenerator = generators.length > 0 ? generators[0] : null; + const pools = [reactors, generators, coolers, directProducers].filter( + (p) => p.length > 0, + ); + const startMs = performance.now(); + const deadlineMs = startMs + timeBudgetMs; + + // Temperature is scaled to the size of one MOVE, not to the island's total + // output. Changing a single tile is worth roughly one generator's power + // wherever it happens, so scaling by total power made large islands run far + // too hot: on the 67-tile map 1 island, ~23% of moves that each cost 7% of + // the layout were being accepted, and the walk never climbed back. + // + // It is read off the PLAIN roster, so under a rule that rates a tile above it + // the walk runs colder than this calibration intends — a move on a Tidal + // shore is worth its `multiplier` times one here. Scaling it by + // `islandRatingCeiling` was measured: Tidal (the pre-nerf + // `tidal_ascendancy_legacy`) on map 3 at 15s over three seeds went + // 176/178/179AC to 179/181/178AC, and Singularity at 30s over five seeds on + // map 3 142/140/142/142/140AC to + // 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to + // 267/271/271/271/274AC — inside run-to-run noise, so the plain figure is kept. + // Anyone re-measuring should use a longer budget and more seeds than that. + const moveScale = + generators.length > 0 + ? generators[0].effectiveValue * generatorEnergyRatio(generators[0]) + : 1.0; + const tStart = Math.max(moveScale * 0.1, 1.0); + const tMin = 1e-4; + let temperature = tStart; + + // Snap back to the best layout after a long run of no improvement. Without + // this, one unlucky sequence of accepted downhill moves strands the walk in a + // region it cannot climb out of for the rest of the budget. + const stallLimit = Math.max(2000, ctx.n * 200); + + let step = 0; + const batchMask = 127; + + /** Accepts or rolls back a trial move, Metropolis-style. */ + const accept = (power: number, rows: SimPlacedBuilding[]): boolean => { + const delta = power - currentPower; + const exponent = temperature > 0 ? delta / temperature : -100.0; + if ( + delta > EPS || + (exponent > -50.0 && rng.random() < Math.exp(exponent)) + ) { + currentPower = power; + currentPlacements = rows; + recordIfBest(power, current, rows); + return true; + } + return false; + }; + + for (;;) { + if (maxSteps !== undefined && step >= maxSteps) break; + + if ((step & batchMask) === 0) { + const now = performance.now(); + if (now >= deadlineMs) break; + + if (pacer.due(now)) { + await pacer.pump(now, () => islandProgress(bestPlacement, ctx)); + if (pacer.stopRequested) break; + } + + const progress = + maxSteps !== undefined + ? Math.min(1.0, step / maxSteps) + : Math.min(1.0, Math.max(0.0, (now - startMs) / timeBudgetMs)); + temperature = tStart * Math.pow(tMin / tStart, progress); + + if (stall >= stallLimit) { + copyInto(current, bestPlacement); + sim = simulateIsland(current, ctx); + currentPower = sim.totalPower; + currentPlacements = sim.placements; + stall = 0; + } + } + + step += 1; + stall += 1; + const moveType = rng.random(); + + if (moveType < 0.15 && currentPlacements.length > 0) { + const uncooled: SimPlacedBuilding[] = []; + const wastedReactors: SimPlacedBuilding[] = []; + for (const p of currentPlacements) { + if ( + p.wasteHeatGenerated > EPS && + !wasteIsCovered(p.wasteHeatGenerated, p.coolingReceived) + ) { + uncooled.push(p); + } + if (isUnderFed(p)) { + wastedReactors.push(p); + } + } + + if (uncooled.length > 0 && topCooler !== null && rng.random() < 0.6) { + const target = rng.choice(uncooled); + const adj = ctx.neighbors[target.idx]; + // Beside the starved producer, which is the only place a cooler can + // reach it. Under a pool any tile would do and a short pool starves + // every producer at once, so this writes over a random occupied + // neighbour there — often the reactor feeding the very producer it + // means to save. Aiming it at an empty tile instead was measured on + // maps 3 and 7 at 15s over three seeds and changed nothing, so the one + // shape is kept. + if (adj.length > 0) { + const tile = adj[rng.int(adj.length)]; + const old = current[tile]; + // In the tile's units, or the guard never fires on a rated tile — + // see `ratedFor`. + if (ratedFor(ctx, tile, topCooler) !== old) { + put(current, ctx, tile, topCooler); + const trial = simulateIsland(current, ctx); + if (!accept(trial.totalPower, trial.placements)) + put(current, ctx, tile, old); + continue; + } + } + } else if ( + wastedReactors.length > 0 && + topGenerator !== null && + rng.random() < 0.6 + ) { + const target = rng.choice(wastedReactors); + const adj = ctx.neighbors[target.idx]; + if (adj.length > 0) { + const tile = adj[rng.int(adj.length)]; + const old = current[tile]; + if (ratedFor(ctx, tile, topGenerator) !== old) { + put(current, ctx, tile, topGenerator); + const trial = simulateIsland(current, ctx); + if (!accept(trial.totalPower, trial.placements)) + put(current, ctx, tile, old); + continue; + } + } + } + } + + if (moveType < 0.5 && ctx.n >= 2) { + const tile1 = rng.int(ctx.n); + const adj = ctx.neighbors[tile1]; + let tile2: number; + if (adj.length > 0 && rng.random() < 0.7) { + tile2 = adj[rng.int(adj.length)]; + } else { + tile2 = rng.int(ctx.n); + while (tile2 === tile1) tile2 = rng.int(ctx.n); + } + + const val1 = current[tile1]; + const val2 = current[tile2]; + // A swap that writes each tile what it already holds. On one scale class + // that is `val1 === val2`; across two it is not, because the same roster + // entry is a different object on each — so the test is on what the writes + // would land, which covers both. + if ( + ratedFor(ctx, tile1, val2) === val1 && + ratedFor(ctx, tile2, val1) === val2 + ) { + continue; + } + + put(current, ctx, tile1, val2); + put(current, ctx, tile2, val1); + + const trial = simulateIsland(current, ctx); + if (!accept(trial.totalPower, trial.placements)) { + put(current, ctx, tile1, val1); + put(current, ctx, tile2, val2); + } + continue; + } + + const tile = rng.int(ctx.n); + const old = current[tile]; + const replacement = randomCandidate(pools, rng); + // The commonest move in the walk, and the commonest no-op: `randomCandidate` + // returns the top tier of a pool with p ~ 0.7, so a packed island proposes + // the building a tile already holds constantly. + if (ratedFor(ctx, tile, replacement) === old) continue; + + put(current, ctx, tile, replacement); + const trial = simulateIsland(current, ctx); + if (!accept(trial.totalPower, trial.placements)) current[tile] = old; + } + + return bestPlacement; +} diff --git a/packages/solver/src/solver/search/composition.ts b/packages/solver/src/solver/search/composition.ts new file mode 100644 index 0000000..a7876ed --- /dev/null +++ b/packages/solver/src/solver/search/composition.ts @@ -0,0 +1,465 @@ +import { EPS } from "../constants"; +import type { IslandContext } from "../context"; +import { generatorCapacities, isolationRoom } from "../island"; +import { Pacer } from "../pacer"; +import { generatorEnergyRatio, generatorWasteRatio } from "../physics"; +import { simulateIsland } from "../simulate"; +import type { EffectiveBuilding, IslandSubGrid, Placement } from "../types"; +import { chebyshev, put, ratedFor, SWAP_RADIUS } from "./placement"; +import { POLISH_REACTOR_TIERS } from "./repair"; +import { anyOfflineProducer, planHolds } from "./stability"; + +/** + * Composition retargeting: deciding WHAT to build is a counting problem, so it + * is computed directly, and the search is left with where to put it. + */ + +/** How many candidate compositions to try arranging, best-scoring first. */ +const COMPOSITION_TARGETS = 3; + +interface ScoredComposition { + power: number; + composition: EffectiveBuilding[]; +} + +/** + * The best MULTISETS of buildings for an island of this size, ignoring where + * they go — highest theoretical power first. + * + * Which buildings to use is a tiny counting problem, not a search problem. For + * counts (nReactor, nGenerator, nCooler) summing to the tile budget, the layout + * can convert at most `min(reactor output, nGenerator * H_max, nCooler * C / + * WASTE_RATIO)` of heat, so enumerating the O(tiles^2) splits and greedily + * filling the reactor tiles with the largest tiers that stay inside that budget + * gives the exact best composition. On the 67-tile map 1 island this returns + * 13 neuro + 1 sauron_eye + 14 generator6 + 39 cooler6 — the known optimum — in + * a few milliseconds, where the search alone plateaued 1.6% below it. + * + * Placement can still make a composition unachievable (a generator can only be + * fed by ADJACENT reactors), which is why several are returned, each with the + * power it would reach if it could be arranged perfectly: the caller tries them + * in order, and can skip any that could not beat what it already has. + */ +export function targetCompositions( + tileCount: number, + reactors: EffectiveBuilding[], + generators: EffectiveBuilding[], + coolers: EffectiveBuilding[], + generatorCapacity: Float64Array | null, + topK = COMPOSITION_TARGETS, +): ScoredComposition[] { + if ( + reactors.length === 0 || + generators.length === 0 || + coolers.length === 0 || + tileCount < 3 + ) { + return []; + } + + const generator = generators[0]; + const cooler = coolers[0]; + // Only the `topK` best are kept as they are found, and a composition's + // array is built only once it has earned a place. Materialising all + // O(tiles^2) of them and sorting afterwards cost 80-130ms on the largest + // shipped islands, nearly all of it allocation. Insertion goes after every + // entry of equal power, which is exactly what the stable descending sort it + // replaces produced: equal scores in enumeration order. + const scored: ScoredComposition[] = []; + const earnsPlace = (power: number): boolean => + scored.length < topK || power > scored[scored.length - 1].power; + const keep = (entry: ScoredComposition): void => { + let at = scored.length; + while (at > 0 && scored[at - 1].power < entry.power) at--; + scored.splice(at, 0, entry); + if (scored.length > topK) scored.pop(); + }; + + for (let nGen = 1; nGen < tileCount - 1; nGen++) { + for (let nCool = 1; nCool < tileCount - nGen; nCool++) { + const nReact = tileCount - nGen - nCool; + if (nReact < 1) continue; + + // Most heat this many generators, and this much cooling, can carry. + const wasteRatio = generatorWasteRatio(generator); + if (wasteRatio <= 0) continue; + const budget = Math.min( + generatorCapacity === null + ? nGen * generator.effectiveValue + : generatorCapacity[nGen], + (nCool * cooler.effectiveValue) / wasteRatio, + ); + + // Two ways to fill the reactor tiles, and which one wins depends on what + // is actually binding. + // + // Packing them with the largest tier maximizes heat. Overshooting the + // budget costs nothing — surplus heat is simply never absorbed and power + // stays capped — so this is what to do when generator or cooling capacity + // is the limit. + // + // Stepping down through tiers to land just under the budget instead + // matters when COOLING is the limit: the all-or-nothing rule shuts a + // generator down entirely if its waste outruns the cooling reaching it, + // so a smaller top-up reactor can be worth more than a larger one. + const fills: EffectiveBuilding[][] = [ + new Array(nReact).fill(reactors[0]), + ]; + + let heat = 0.0; + const mix: EffectiveBuilding[] = []; + for (const tier of reactors.slice(0, POLISH_REACTOR_TIERS)) { + if (tier.effectiveValue <= 0) continue; + const take = Math.min( + nReact - mix.length, + Math.floor((budget - heat) / tier.effectiveValue), + ); + if (take > 0) { + heat += take * tier.effectiveValue; + for (let i = 0; i < take; i++) mix.push(tier); + } + } + if (mix.length === nReact && !sameFill(mix, fills[0])) fills.push(mix); + + for (const fill of fills) { + let fillHeat = 0.0; + for (const b of fill) fillHeat += b.effectiveValue; + const absorbed = Math.min(fillHeat, budget); + const power = absorbed * generatorEnergyRatio(generator); + if (power > EPS && earnsPlace(power)) { + const composition = fill.slice(); + for (let i = 0; i < nGen; i++) composition.push(generator); + for (let i = 0; i < nCool; i++) composition.push(cooler); + keep({ power, composition }); + } + } + } + } + + return scored; +} + +/** + * How much heat `nGen` generator tiles on THIS island can take in, or null + * under every rule that leaves the count alone. + * + * `targetCompositions` decides what to build by counting, and the count it + * wants is a function of what a generator is worth. Under a role isolation + * that is two numbers rather than one — a generator with no generator beside + * it takes 4x, one with 0.8x — and which a tile gets is decided by the + * layout, so neither belongs in the pool the stage draws on. The roster cannot + * express it; the island can, and `isolationRoom` is how much of the bonus its + * shape has room for. + * + * Both halves are load-bearing. Sizing at the bonus alone asks Gale Hills at + * generator7 tier 1 for 20 generators on an island that can keep 15 apart, and + * `arrangeComposition` cannot spread that many without most of them touching. + * With the room folded in it asks for 16, and over ten runs at 15s the mean + * goes 44.6AC to 46.2AC; the best either reaches is 49.8AC, fifteen generators + * every one isolated. Over three seeds it is worth +12-14% on maps 7 and 8 and + * costs 1-4% on maps 2, 5 and 6 — a net gain, not a uniform one. + * + * The same table the bound runs on (`island.ts`), and deliberately so — one + * reading of what a board has room for, rather than a second one here to drift + * against it. It is built from the island's own ratings rather than the + * capped figures the bound uses: a bound may relax adjacency away, a target + * has to be something the board can hold. + */ +export function generatorCapacityTable( + island: IslandSubGrid, + ctx: IslandContext, + generator: EffectiveBuilding | undefined, +): Float64Array | null { + if (generator === undefined) return null; + + const isolated = ctx.rateIsolated(generator, false).effectiveValue; + const crowded = ctx.rateIsolated(generator, true).effectiveValue; + // Equal under every other rule, and under one that rates a crowded building + // no lower there is no ceiling to respect: the count is linear again. + if (crowded >= isolated) return null; + + return generatorCapacities(ctx.n, isolated, { + gTake: crowded, + room: isolationRoom(island), + }); +} + +/** + * The largest factor anything on this island can be rated at — 1 under the rules + * that leave the roster alone. + * + * `targetCompositions` scores a target from the plain roster, and it is right to: + * which buildings to use is a counting problem over the roster, and the counting + * is the same whatever the tiles are worth. But the layout the caller compares it + * against has been rated tile by tile, so the two figures are in different units, + * and a target's ceiling read as-is says "this cannot beat what I have" about + * every target on the list. The gate is a `break`, so the whole stage goes with + * it — silently, and hardest under the anomaly that most needs it: on island3 + * under Tidal a 0.6s run already returns 1.68e23 against a top target of + * 1.49e23, so the stage never ran at any realistic budget. + * + * Scaling the target by one island-wide factor is the move `estimateTotalMaxPower` + * makes to keep its bound a bound wherever its mask cannot say which tiles carry + * the factor (`islandMaxScale`), and it is sound for the same reason: a + * composition's power is positively homogeneous of degree 1 in + * the roster's figures, so rating the whole island at its best tile is an upper + * bound on rating each tile at its own. Loose where only part of an island + * qualifies, which is the right way to be wrong here — a ceiling that is too low + * skips a stage that would have helped, while one that is too high costs a few + * arrangement attempts that fail to beat the layout in hand. + * + * It is asked of the context rather than read off the anomaly so that a rule + * resolved per tile (`rate`) and one resolved per layout (`rateIsolated`) are + * both answered by the code that actually applies them, and a fifth rule shape + * needs nothing here at all. + */ +export function islandRatingCeiling( + ctx: IslandContext, + probes: readonly EffectiveBuilding[], + sizedByIsolation: boolean, +): number { + if (ctx.uniformRating && (ctx.isolation === null || sizedByIsolation)) + return 1; + + let ceiling = 1; + const consider = (factor: number): void => { + if (factor > ceiling) ceiling = factor; + }; + + for (const probe of probes) { + if (probe.effectiveValue <= 0) continue; + if (!ctx.uniformRating) { + for (let tile = 0; tile < ctx.n; tile++) { + consider(ctx.rate(tile, probe).effectiveValue / probe.effectiveValue); + } + } + if (ctx.isolation !== null && !sizedByIsolation) { + // Both variants, because which one a tile gets is a function of the + // layout: a search free to keep generators apart rates every one of them + // at the bonus. The same reasoning `islandMaxScale` carries. + consider( + ctx.rateIsolated(probe, false).effectiveValue / probe.effectiveValue, + ); + consider( + ctx.rateIsolated(probe, true).effectiveValue / probe.effectiveValue, + ); + } + } + return ceiling; +} + +/** + * Whether a composition target could still beat the layout in hand, which is what + * decides whether the retarget stage runs at all. + * + * `target.power` is the power a perfect arrangement of the target would reach at + * the PLAIN roster's figures and `bestPower` is what a rated layout actually + * scored, so the ceiling is what puts the two in the same units — see + * `islandRatingCeiling`. The caller's gate is a `break` over a descending list, + * so answering `false` once ends the stage: a named function so the reading can + * be pinned, since it is the difference between a stage that runs and one that is + * silently never entered. + */ +export function targetCanBeat( + target: ScoredComposition, + bestPower: number, + ratingCeiling: number, +): boolean { + return target.power * ratingCeiling > bestPower + EPS; +} + +function sameFill(a: EffectiveBuilding[], b: EffectiveBuilding[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +function countById( + buildings: Iterable, +): Map { + const counts = new Map(); + for (const b of buildings) counts.set(b.id, (counts.get(b.id) ?? 0) + 1); + return counts; +} + +function countPlacement(placement: Placement): Map { + const counts = new Map(); + for (const b of placement) { + if (b !== null) counts.set(b.id, (counts.get(b.id) ?? 0) + 1); + } + return counts; +} + +function sameCounts(a: Map, b: Map): boolean { + if (a.size !== b.size) return false; + for (const [id, n] of a) if (b.get(id) !== n) return false; + return true; +} + +/** + * Places a fixed multiset of buildings, then improves the ARRANGEMENT only. + * + * Starting from `reference` (the best layout found so far), tiles are converted + * to match the target counts, choosing at each step the change that costs least + * power. The result is then improved with swaps, which move buildings around + * without changing what is on the board — so every candidate keeps the target's + * global heat/cooling budget and the search is over arrangements alone. + */ +export async function arrangeComposition( + composition: EffectiveBuilding[], + reference: Placement, + ctx: IslandContext, + deadlineMs: number, + pacer: Pacer, +): Promise { + if (composition.length > ctx.n) return null; + + const wanted = countById(composition); + const byId = new Map(); + for (const b of composition) byId.set(b.id, b); + const current = reference.slice(); + + // 1. Reconcile counts with the target, cheapest change first. + while (performance.now() < deadlineMs && !pacer.stopRequested) { + const have = countPlacement(current); + const surplus = new Set(); + for (const [id, n] of have) { + if (n > (wanted.get(id) ?? 0)) surplus.add(id); + } + + let targetId: string | null = null; + for (const [id, n] of wanted) { + if (n > (have.get(id) ?? 0)) { + targetId = id; + break; + } + } + + let bestMove = -1; + let bestPower = -1.0; + + if (targetId === null) { + // Too many buildings overall: drop the least useful surplus tile. + if (surplus.size === 0) break; + for (let tile = 0; tile < ctx.n; tile++) { + const held = current[tile]; + if (held === null || !surplus.has(held.id)) continue; + current[tile] = null; + const power = simulateIsland(current, ctx).totalPower; + put(current, ctx, tile, held); + if (power > bestPower) { + bestPower = power; + bestMove = tile; + } + } + if (bestMove < 0) break; + current[bestMove] = null; + } else { + const building = byId.get(targetId)!; + for (let tile = 0; tile < ctx.n; tile++) { + if (current[tile] !== null) continue; + put(current, ctx, tile, building); + const power = simulateIsland(current, ctx).totalPower; + current[tile] = null; + if (power > bestPower) { + bestPower = power; + bestMove = tile; + } + } + if (bestMove < 0) { + for (let tile = 0; tile < ctx.n; tile++) { + const held = current[tile]; + if (held === null || !surplus.has(held.id)) continue; + put(current, ctx, tile, building); + const power = simulateIsland(current, ctx).totalPower; + put(current, ctx, tile, held); + if (power > bestPower) { + bestPower = power; + bestMove = tile; + } + } + } + if (bestMove < 0) return null; + put(current, ctx, bestMove, building); + } + + const now = performance.now(); + if (pacer.due(now)) await pacer.pump(now); + } + + if (!sameCounts(countPlacement(current), wanted)) return null; + + // 2. Improve the arrangement with swaps, which preserve the composition. + // Only nearby pairs are tried: swapping two buildings changes the layout + // solely through who they are adjacent to, so a swap between opposite ends of + // the island is two independent local changes and is better found as two + // separate moves. Restricting to Chebyshev distance 2 cuts the pairs to a + // fraction of all-pairs on a large island. + const occupied: number[] = []; + for (let tile = 0; tile < ctx.n; tile++) { + if (current[tile] !== null) occupied.push(tile); + } + // Plain ascending (x, y) order, which is NOT the tile index order + // (ascending X, DESCENDING Y). The fixtures pin this pairing exactly. + occupied.sort((a, b) => ctx.xs[a] - ctx.xs[b] || ctx.ys[a] - ctx.ys[b]); + + const pairs: [number, number][] = []; + for (let i = 0; i < occupied.length; i++) { + for (let j = i + 1; j < occupied.length; j++) { + if (chebyshev(ctx, occupied[i], occupied[j]) <= SWAP_RADIUS) { + pairs.push([occupied[i], occupied[j]]); + } + } + } + + while (performance.now() < deadlineMs && !pacer.stopRequested) { + const basePower = simulateIsland(current, ctx).totalPower; + let bestGain = 0.0; + let bestSwap: [number, number] | null = null; + + for (let index = 0; index < pairs.length; index++) { + if ((index & 63) === 0) { + const now = performance.now(); + if (now >= deadlineMs) break; + if (pacer.due(now)) { + await pacer.pump(now); + if (pacer.stopRequested) break; + } + } + + const [a, b] = pairs[index]; + const valA = current[a]; + const valB = current[b]; + // Swapping identical buildings changes nothing — asked in each tile's own + // units, so two scale classes holding the same roster entry still count + // as identical. See `ratedFor`. + if (ratedFor(ctx, a, valB) === valA && ratedFor(ctx, b, valA) === valB) { + continue; + } + + put(current, ctx, a, valB); + put(current, ctx, b, valA); + + const trial = simulateIsland(current, ctx); + if ( + trial.totalPower - basePower > bestGain && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + bestGain = trial.totalPower - basePower; + bestSwap = [a, b]; + } + + put(current, ctx, a, valA); + put(current, ctx, b, valB); + } + + if (bestSwap === null) break; + const [a, b] = bestSwap; + const held = current[a]; + put(current, ctx, a, current[b]); + put(current, ctx, b, held); + } + + return current; +} diff --git a/packages/solver/src/solver/search/placement.ts b/packages/solver/src/solver/search/placement.ts new file mode 100644 index 0000000..31467ee --- /dev/null +++ b/packages/solver/src/solver/search/placement.ts @@ -0,0 +1,111 @@ +import type { IslandContext } from "../context"; +import type { SimPlacedBuilding } from "../simulate"; +import type { EffectiveBuilding, PlacedBuilding, Placement } from "../types"; + +/** + * The placement primitives every stage of the search shares: writing a + * building onto a tile in that tile's units, the cheap geometry the moves are + * built from, and turning simulation rows into the rows a result carries. + */ + +/** Max Chebyshev distance between two tiles considered for an arrangement swap. */ +export const SWAP_RADIUS = 2; + +export function emptyPlacement(n: number): Placement { + return new Array(n).fill(null); +} + +/** + * Writes a building onto a tile, rated for that tile. + * + * The one way a building enters a placement, so that a terrain bonus cannot be + * missed at one of three dozen sites — and a miss would be silent, since the + * layout would simply be worth less than it is. `ctx.rate` is the identity + * under every rule but a terrain bonus, and idempotent under that one, so this + * is equally correct for a fresh placement, a swap between two tiles, and the + * restore that undoes a rejected move. + * + * Clearing a tile stays a plain `= null`: a null carries no rating, so there is + * nothing to resolve and nothing to get wrong. + */ +export function put( + placement: Placement, + ctx: IslandContext, + tile: number, + building: EffectiveBuilding | null, +): void { + placement[tile] = ratedFor(ctx, tile, building); +} + +/** + * Exactly what `put` would write onto `tile` — the building as that tile rates + * it, or `null`. + * + * This is how a move asks "would writing this change anything?", and it has to + * be asked in the tile's own units: a placement holds RATED buildings, so + * comparing a plain roster entry against one is never equal on a scaled tile, + * and the guard that should have skipped the move instead writes back an + * identical object, runs a full `simulateIsland`, and hands `accept` a delta of + * zero — which it "accepts". No number moves, and the walk quietly spends a + * slice of a budget whose only product is steps per second. + * + * The comparison is exact rather than approximate because `ctx.rate` caches one + * object per (scale, roster entry) pair, so two tiles of the same scale holding + * the same building hold the *same* object — and it is cheap for the same + * reason: a lookup, not a multiply. + */ +export function ratedFor( + ctx: IslandContext, + tile: number, + building: EffectiveBuilding | null, +): EffectiveBuilding | null { + return building === null ? null : ctx.rate(tile, building); +} + +export function copyInto(dst: Placement, src: Placement): void { + for (let i = 0; i < dst.length; i++) dst[i] = src[i]; +} + +export function isReactor(b: EffectiveBuilding): boolean { + return b.type === "reactor"; +} + +export function isDirectProducer(b: EffectiveBuilding): boolean { + return b.type === "direct_producer"; +} + +export function chebyshev(ctx: IslandContext, a: number, b: number): number { + return Math.max( + Math.abs(ctx.xs[a] - ctx.xs[b]), + Math.abs(ctx.ys[a] - ctx.ys[b]), + ); +} + +/** The tile's Chebyshev-adjacent buildable neighbors that are still unclaimed. */ +export function availableNeighbors( + tile: number, + available: Uint8Array, + ctx: IslandContext, +): number[] { + const out: number[] = []; + const nb = ctx.neighbors[tile]; + for (let k = 0; k < nb.length; k++) { + if (available[nb[k]]) out.push(nb[k]); + } + return out; +} + +export function toPlacedBuildings(rows: SimPlacedBuilding[]): PlacedBuilding[] { + return rows.map((r) => ({ + x: r.x, + y: r.y, + buildingId: r.buildingId, + baseValue: r.baseValue, + powerGenerated: r.powerGenerated, + heatProduced: r.heatProduced, + heatConsumed: r.heatConsumed, + wasteHeatGenerated: r.wasteHeatGenerated, + coolingProvided: r.coolingProvided, + coolingReceived: r.coolingReceived, + })); +} diff --git a/packages/solver/src/solver/search/pooledCoolers.ts b/packages/solver/src/solver/search/pooledCoolers.ts new file mode 100644 index 0000000..608a44d --- /dev/null +++ b/packages/solver/src/solver/search/pooledCoolers.ts @@ -0,0 +1,405 @@ +import type { IslandContext } from "../context"; +import { type SimPlacedBuilding, simulateIsland } from "../simulate"; +import type { EffectiveBuilding, Placement } from "../types"; +import { + chebyshev, + copyInto, + emptyPlacement, + isReactor, + put, + ratedFor, + SWAP_RADIUS, +} from "./placement"; +import { covers } from "./rightSize"; +import { anyOfflineProducer, planHolds } from "./stability"; + +/** + * Under a shared cooling pool only: gathering the coolers the search scattered, + * after moving hubs off the smaller landmasses so those can hold them. + */ + +/** + * How long `gatherPooledCoolers` may spend, per solve on the primary layout and + * once more shared across its alternates. It runs after the search's deadline, + * so this is time added to a run. Moving a cooler onto an empty tile costs + * nothing. The cap is for the cooler-to-producer swaps, which mostly fail and + * cost a simulation each. On the shipped maps one layout finishes in 15-110ms. + */ +export const GATHER_BUDGET_MS = 150; + +/** + * How long `evacuateMinorLandmasses` may spend, on top of `GATHER_BUDGET_MS` + * and taken first. Spent only on a board with producers on more than one + * landmass, and the pass stops as soon as a hub is placed — moving a + * 15-producer hub costs about 170ms on a desktop. The cap is sized for a phone, + * several times slower, where a tight one would make the pass silently do + * nothing; a second is small beside a 10-30s attempt. Out of time, the layout + * stays as it was. + */ +export const EVACUATE_BUDGET_MS = 1000; + +/** Chebyshev radius of the patch `gatherPooledCoolers` anchors on. */ +const GATHER_ANCHOR_RADIUS = 2; + +/** Each tile's landmass, numbered from 0, and how many there are. */ +function landmasses(ctx: IslandContext): { + component: Int32Array; + count: number; +} { + const component = new Int32Array(ctx.n).fill(-1); + let count = 0; + for (let start = 0; start < ctx.n; start++) { + if (component[start] >= 0) continue; + const id = count++; + const stack = [start]; + component[start] = id; + while (stack.length > 0) { + const nb = ctx.neighbors[stack.pop()!]; + for (let k = 0; k < nb.length; k++) { + if (component[nb[k]] < 0) { + component[nb[k]] = id; + stack.push(nb[k]); + } + } + } + } + return { component, count }; +} + +/** + * Under a shared cooling pool, moves the hubs off every landmass but the main + * one, so that landmass can be given to the coolers. + * + * `gatherPooledCoolers` moves one tile at a time, and that cannot move a hub: + * a generator taken from its reactors loses power at every step of the way, so + * the hub on a small second landmass stays there and its coolers stay with it. + * This moves a landmass's producers all at once instead. Each takes a tile on + * the main landmass, the one holding the most producers, and the cooler it + * displaces goes to one of the tiles the producers left — so the pool's total + * and the board's composition never change, and the landmass comes out + * producer-free for the sweep to fill. + * + * The producers are placed greedily, whichever of them is worth the most power + * on whichever free tile goes next, and then the main landmass is swapped + * into shape until power is back (`relocateHub`). A landmass is evacuated only if the result + * holds the power floor, keeps every producer online and passes the upgrade + * plan; otherwise the layout is left exactly as it was. Smallest first, since a + * hub of a few producers is the likeliest to fit. + */ +function evacuateMinorLandmasses( + current: Placement, + ctx: IslandContext, + component: Int32Array, + count: number, + floor: number, + deadlineMs: number, +): void { + const producersOn = new Array(count).fill(0); + for (let t = 0; t < ctx.n; t++) { + const b = current[t]; + if (b !== null && b.type !== "cooler") producersOn[component[t]]++; + } + let main = 0; + for (let c = 1; c < count; c++) + if (producersOn[c] > producersOn[main]) main = c; + + const minors: number[] = []; + for (let c = 0; c < count; c++) + if (c !== main && producersOn[c] > 0) minors.push(c); + minors.sort((a, b) => producersOn[a] - producersOn[b] || a - b); + + const trial = current.slice(); + for (const minor of minors) { + if (performance.now() >= deadlineMs) return; + copyInto(trial, current); + if (relocateHub(trial, ctx, component, minor, main, floor, deadlineMs)) + copyInto(current, trial); + } +} + +/** + * Moves every producer on `minor` onto `main`, in place. True when the result + * is a layout `evacuateMinorLandmasses` may keep; `trial` is junk otherwise. + */ +function relocateHub( + trial: Placement, + ctx: IslandContext, + component: Int32Array, + minor: number, + main: number, + floor: number, + deadlineMs: number, +): boolean { + const movers: EffectiveBuilding[] = []; + const vacated: number[] = []; + for (let t = 0; t < ctx.n; t++) { + const b = trial[t]; + if (component[t] !== minor || b === null || b.type === "cooler") continue; + movers.push(b); + vacated.push(t); + trial[t] = null; + } + + // A tile on the main landmass a producer may take: empty, or a cooler that + // can step across to the landmass being emptied. + const free = (t: number): boolean => + component[t] === main && (trial[t] === null || trial[t]!.type === "cooler"); + const freeAround = (t: number): number => { + let n = 0; + for (const u of ctx.neighbors[t]) if (free(u)) n++; + return n; + }; + // A reactor makes power only through a generator beside it, so a tile with + // none is not worth simulating. + const besideGenerator = (t: number): boolean => { + for (const u of ctx.neighbors[t]) + if (trial[u] !== null && trial[u]!.type === "generator") return true; + return false; + }; + + let nextVacated = 0; + while (movers.length > 0) { + if (performance.now() >= deadlineMs) return false; + + let bestPower = -Infinity; + let bestRoom = -1; + let bestTile = -1; + let bestMover = -1; + // A reactor beside no generator is still placed when nothing better is + // left — a generator that took the strongest tile may have taken the last + // free one beside it too — and the swap descent below moves it into place. + for (const anywhere of [false, true]) { + const tried = new Set(); + for (let m = 0; m < movers.length; m++) { + const b = movers[m]; + if (tried.has(b.id)) continue; + tried.add(b.id); + for (const t of ctx.tiles) { + if (!free(t)) continue; + if (!anywhere && isReactor(b) && !besideGenerator(t)) continue; + const held = trial[t]; + put(trial, ctx, t, b); + const power = simulateIsland(trial, ctx).totalPower; + trial[t] = held; + // On a tie, the tile with the most room left around it, so a + // generator lands where its reactors can follow. + const room = freeAround(t); + if (power > bestPower || (power === bestPower && room > bestRoom)) { + bestPower = power; + bestRoom = room; + bestTile = t; + bestMover = m; + } + } + } + if (bestTile >= 0) break; + } + if (bestTile < 0) return false; + + const displaced = trial[bestTile]; + put(trial, ctx, bestTile, movers[bestMover]); + // The displaced cooler crosses over at once, so the pool is whole while + // the rest of the hub is placed. + if (displaced !== null) put(trial, ctx, vacated[nextVacated++], displaced); + movers.splice(bestMover, 1); + } + + // The greedy order cannot see that a reactor placed early sits on the tile a + // later generator wanted, and the hubs already on the main landmass were + // built around a board that had no room for these. So: a swap descent over + // the whole landmass, nearby pairs only (as `arrangeComposition` does), first + // gain taken, until the floor is met. Swaps never change what is on the + // board, and the emptied landmass is not in the pairs, so nothing returns to + // it. On a cleared Entropy Isles under Cryo the greedy reaches 98.5% of the + // floor and one pass of this closes the rest. + const onMain: number[] = []; + for (const t of ctx.tiles) if (component[t] === main) onMain.push(t); + const pairs: [number, number][] = []; + for (let i = 0; i < onMain.length; i++) + for (let j = i + 1; j < onMain.length; j++) + if (chebyshev(ctx, onMain[i], onMain[j]) <= SWAP_RADIUS) + pairs.push([onMain[i], onMain[j]]); + + const producer = (b: EffectiveBuilding | null): boolean => + b !== null && b.type !== "cooler"; + let power = simulateIsland(trial, ctx).totalPower; + let improved = true; + while (improved && !covers(power, floor)) { + improved = false; + for (const [a, b] of pairs) { + if (performance.now() >= deadlineMs) return false; + const va = trial[a]; + const vb = trial[b]; + // Coolers and empty tiles are interchangeable under the pool. + if (!producer(va) && !producer(vb)) continue; + if (ratedFor(ctx, a, vb) === va) continue; + put(trial, ctx, a, vb); + put(trial, ctx, b, va); + const swapped = simulateIsland(trial, ctx).totalPower; + if (swapped > power) { + power = swapped; + improved = true; + if (covers(power, floor)) break; + continue; + } + put(trial, ctx, a, va); + put(trial, ctx, b, vb); + } + } + + const final = simulateIsland(trial, ctx); + return ( + covers(final.totalPower, floor) && + !anyOfflineProducer(trial, final.placements) && + planHolds(trial, ctx) + ); +} + +/** + * Under a shared cooling pool, pulls the coolers into one area of the board + * and onto the small islands no producer is using. + * + * Under the pool a cooler's tile does not matter. Every cooler pays into one + * figure (`simulateIsland`), so power cannot tell a cooler in the middle of the + * hubs from one at the edge. The walk leaves coolers wherever a hub left a gap, + * scattered across the landmass, and that board is harder to read and to build + * than the same layout with its coolers together. + * + * Like `downgradeOversized`, this cannot find a better layout. It only moves + * coolers, swapping each with a tile nearer the anchor. Moving onto an empty + * tile always holds, because the pool's total and every producer are + * untouched. A swap with a producer moves that producer into the cooler's old + * tile, so it is re-simulated and kept only if power holds, every producer + * stays online and the upgrade plan still runs. + * + * The anchor is the tile with the most coolers within `GATHER_ANCHOR_RADIUS`, + * so the coolers gather on the patch where most of them already stand. Anchoring + * on the coolers' centroid was measured too: it put the anchor among the hubs, + * where a swap rarely holds, and left more clusters on maps 1 and 8. A tile on a + * landmass with no producer is a scrap nothing else can use, and counts as + * nearer than any tile on a working landmass, so empty scraps fill first. Every + * accepted move strictly lowers the coolers' summed distance, so the sweep + * terminates; the deadline only bounds how far it gets. + */ +export function gatherPooledCoolers( + rows: SimPlacedBuilding[], + power: number, + effectiveBuildings: EffectiveBuilding[], + ctx: IslandContext, + deadlineMs: number, + evacuateDeadlineMs = Math.min( + deadlineMs, + performance.now() + EVACUATE_BUDGET_MS, + ), +): { rows: SimPlacedBuilding[]; power: number } { + if (ctx.anomaly.rule !== "shared_cooling" || rows.length === 0) + return { rows, power }; + + const byId = new Map(effectiveBuildings.map((b) => [b.id, b])); + const current = emptyPlacement(ctx.n); + for (const row of rows) { + const building = byId.get(row.buildingId); + if (building === undefined) return { rows, power }; + put(current, ctx, row.idx, building); + } + + const { component, count } = landmasses(ctx); + // The callers' deadline covers both budgets; capping the evacuation at its + // own (the default) leaves the sweep at least `GATHER_BUDGET_MS`. + evacuateMinorLandmasses( + current, + ctx, + component, + count, + power, + evacuateDeadlineMs, + ); + + // Which landmasses hold a producer, read after the evacuation so a landmass + // it emptied fills with coolers first, like any other scrap. + const working = new Array(count).fill(false); + for (let t = 0; t < ctx.n; t++) { + const b = current[t]; + if (b !== null && b.type !== "cooler") working[component[t]] = true; + } + + const movable = (t: number): boolean => { + const b = current[t]; + return b !== null && b.type === "cooler" && working[component[t]]; + }; + const placed: number[] = []; + for (let t = 0; t < ctx.n; t++) if (movable(t)) placed.push(t); + if (placed.length === 0) return { rows, power }; + + let anchor = -1; + let densest = -1; + for (let t = 0; t < ctx.n; t++) { + if (!working[component[t]]) continue; + let near = 0; + for (const u of placed) + if (chebyshev(ctx, t, u) <= GATHER_ANCHOR_RADIUS) near++; + if (near > densest) { + densest = near; + anchor = t; + } + } + + /** Squared distance to the anchor; a scrap tile is nearer than any. */ + const distance = new Float64Array(ctx.n); + for (let t = 0; t < ctx.n; t++) { + distance[t] = working[component[t]] + ? (ctx.xs[t] - ctx.xs[anchor]) ** 2 + (ctx.ys[t] - ctx.ys[anchor]) ** 2 + : -1; + } + // Nearest first, ascending index on a tie, so the result depends only on the + // board. + const byDistance = Array.from(ctx.tiles).sort( + (a, b) => distance[a] - distance[b] || a - b, + ); + + const floor = power; + let changed = true; + while (changed && performance.now() < deadlineMs) { + changed = false; + // Farthest cooler first: it has the most to gain from a move. + const coolers: number[] = []; + for (let t = 0; t < ctx.n; t++) if (movable(t)) coolers.push(t); + coolers.sort((a, b) => distance[b] - distance[a] || a - b); + + for (const from of coolers) { + if (performance.now() >= deadlineMs) break; + const cooler = current[from]!; + for (const to of byDistance) { + if (distance[to] >= distance[from]) break; + const other = current[to]; + if (other !== null && other.type === "cooler") continue; + + put(current, ctx, to, cooler); + put(current, ctx, from, other); + if (other === null) { + // The pool's total and every producer are unchanged. + changed = true; + break; + } + const trial = simulateIsland(current, ctx); + if ( + covers(trial.totalPower, floor) && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + changed = true; + break; + } + put(current, ctx, from, cooler); + put(current, ctx, to, other); + if (performance.now() >= deadlineMs) break; + } + } + } + + const final = simulateIsland(current, ctx); + // A move to an empty tile is exact, and a swap had to hold the floor, so a + // shortfall here would be a bug rather than a trade. Give the input back. + if (!covers(final.totalPower, floor)) return { rows, power }; + return { rows: final.placements, power: final.totalPower }; +} diff --git a/packages/solver/src/solver/search/repair.ts b/packages/solver/src/solver/search/repair.ts new file mode 100644 index 0000000..4dbeda3 --- /dev/null +++ b/packages/solver/src/solver/search/repair.ts @@ -0,0 +1,261 @@ +import { EPS } from "../constants"; +import type { IslandContext } from "../context"; +import { type IslandProgress, Pacer } from "../pacer"; +import { type SimPlacedBuilding, simulateIsland } from "../simulate"; +import type { EffectiveBuilding, Placement } from "../types"; +import { + chebyshev, + isReactor, + put, + ratedFor, + toPlacedBuildings, +} from "./placement"; +import { anyOfflineProducer, planHolds, stabilize } from "./stability"; + +/** + * Local repair and pruning: steepest ascent over single-tile changes, and the + * closing sweep that drops dead weight from a finished layout. + */ + +/** How many reactor tiers the local-repair pass considers per tile. */ +export const POLISH_REACTOR_TIERS = 3; +/** + * How far from an applied repair move the cached gains are re-measured. A + * change on a tile is felt through its neighbours' supply and demand, so a + * tile two steps away shares a neighbour with it and one three steps away + * shares nothing — see `greedyPolish`. + */ +const POLISH_DIRTY_RADIUS = 2; + +/** + * The lean set of buildings worth trying on a tile during local repair. + * + * Generators and coolers jump 400x-4100x per tier, so only their top tier can + * ever appear in a good layout. Reactors only jump ~8x, which is small enough + * that a weaker one is genuinely useful for topping up a generator's spare heat + * capacity when there is leftover cooling, so a few reactor tiers are kept. + * Every extra candidate costs another full pass over the island. + */ +export function polishCandidates( + reactors: EffectiveBuilding[], + generators: EffectiveBuilding[], + coolers: EffectiveBuilding[], + directProducers: EffectiveBuilding[], +): (EffectiveBuilding | null)[] { + const candidates: (EffectiveBuilding | null)[] = reactors.slice( + 0, + POLISH_REACTOR_TIERS, + ); + if (generators.length > 0) candidates.push(generators[0]); + if (coolers.length > 0) candidates.push(coolers[0]); + if (directProducers.length > 0) candidates.push(directProducers[0]); + // Leaving the tile empty is a real option. + candidates.push(null); + return candidates; +} + +/** + * Steepest-ascent repair: repeatedly applies the single-tile change that raises + * power the most, considering only stable layouts, until no single change + * helps. + * + * The annealing walk handles small islands well, but on a large, fully packed + * one nearly every single-tile change costs several percent, so the walk drifts + * downhill and rarely climbs back: measured on a 67-tile island, only 1 + * evaluation in 26,000 beat its own starting layout, while 20 stable improving + * moves sat a single move away. A full sweep here costs |tiles| x |candidates| + * simulations and finds them directly. + */ +export async function greedyPolish( + placement: Placement, + ctx: IslandContext, + candidates: (EffectiveBuilding | null)[], + deadlineMs: number, + pacer: Pacer, +): Promise<{ placement: Placement; power: number }> { + let current = placement.slice(); + const sim = simulateIsland(current, ctx); + let bestPower = sim.totalPower; + if (anyOfflineProducer(current, sim.placements) || !planHolds(current, ctx)) { + const stabilized = stabilize(current, ctx); + current = stabilized.placement; + bestPower = stabilized.power; + } + + // The gain of every (tile, candidate) change, measured against the layout + // as it stood when that tile was last swept. A full sweep after every + // accepted move was the whole cost of this stage — |tiles| x |candidates| + // simulations per move, 2-3s for one repair on the largest shipped islands + // — and nearly all of it re-measured changes far from the move, whose gain + // the move cannot have touched: a single-tile change is felt through its + // neighbours' supply and demand, so only tiles within `POLISH_DIRTY_RADIUS` + // of an applied move are swept again. The rest keep their figure, which is + // approximate once anything nearby has moved, so a cached winner is always + // re-simulated before it is applied and dropped back into the sweep if it + // no longer holds. Every applied change is therefore verified exactly; + // only the order the changes are found in can differ from a full sweep. + // + // Measured with the survey (`--runs 3 --seed 1`, every map and anomaly): + // at 15s per map it is a wash, every map inside the noise band; at 5s — + // where the polish share binds, as it does on a phone at any budget — Tidal + // gains 5-9% on six of the eight maps and Shadowspire under Singularity + // 16%, against one 83-tile island down 0.7%. One repair on the seed of the + // largest island went from 950ms to 180ms (`npm run profile`). + const nCandidates = candidates.length; + const gains = new Float64Array(ctx.n * nCandidates).fill(-Infinity); + const dirty = new Uint8Array(ctx.n).fill(1); + + while (performance.now() < deadlineMs && !pacer.stopRequested) { + let cut = false; + for (let tile = 0; tile < ctx.n; tile++) { + if (dirty[tile] === 0) continue; + const now = performance.now(); + if (now >= deadlineMs) { + cut = true; + break; + } + if (pacer.due(now)) { + await pacer.pump(now, () => islandProgress(current, ctx)); + if (pacer.stopRequested) { + cut = true; + break; + } + } + + dirty[tile] = 0; + const held = current[tile]; + for (let k = 0; k < nCandidates; k++) { + const building = candidates[k]; + let gain = -Infinity; + // The candidate list is the plain roster, `held` is rated for this tile: + // asked the wrong way round, a scaled tile re-simulates its own layout + // once per sweep for nothing. See `ratedFor`. + if (ratedFor(ctx, tile, building) !== held) { + put(current, ctx, tile, building); + const trial = simulateIsland(current, ctx); + if ( + trial.totalPower - bestPower > 0 && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + gain = trial.totalPower - bestPower; + } + put(current, ctx, tile, held); + } + gains[tile * nCandidates + k] = gain; + } + } + if (cut) break; + + let bestGain = 0.0; + let bestTile = -1; + let bestCandidate = -1; + for (let i = 0; i < gains.length; i++) { + if (gains[i] > bestGain) { + bestGain = gains[i]; + bestTile = (i / nCandidates) | 0; + bestCandidate = i - bestTile * nCandidates; + } + } + if (bestTile < 0) break; + + // Exact, whether the figure was fresh or cached: the one simulation that + // decides what this stage hands on. + const held = current[bestTile]; + put(current, ctx, bestTile, candidates[bestCandidate]); + const trial = simulateIsland(current, ctx); + if ( + trial.totalPower - bestPower > 0 && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + bestPower = trial.totalPower; + for (let tile = 0; tile < ctx.n; tile++) { + if (chebyshev(ctx, tile, bestTile) <= POLISH_DIRTY_RADIUS) + dirty[tile] = 1; + } + } else { + // Stale: the layout moved under it. Back to the sweep for a fresh figure. + put(current, ctx, bestTile, held); + dirty[bestTile] = 1; + } + } + + return { placement: current, power: bestPower }; +} + +/** + * Post-search cleanup: + * 1. Drops every producer generating 0 power, repeating until none remain. + * 2. Sequentially tests removing reactors/coolers to eliminate redundant + * support structures. + * 3. Guarantees a stable layout — every returned producer is online — with a + * minimal active tile footprint. + * + * Step 1 is unconditional: an overheating building is never acceptable, even + * when keeping it would score higher (see `stabilize`). Step 2 is guarded, + * since removing support can only ever cost power. + */ +export function pruneDeadWeight( + placement: Placement, + ctx: IslandContext, +): { rows: SimPlacedBuilding[]; power: number } { + let anyOccupied = false; + for (let tile = 0; tile < ctx.n; tile++) { + if (placement[tile] !== null) { + anyOccupied = true; + break; + } + } + if (!anyOccupied) return { rows: [], power: 0.0 }; + + // 1. Remove producers that generate no power (cascading). + const stabilized = stabilize(placement, ctx); + const current = stabilized.placement; + let bestPower = stabilized.power; + let rows = stabilized.rows; + + // 2. Greedy backward sweep: test removing support buildings (reactors & coolers). + const support: number[] = []; + for (let tile = 0; tile < ctx.n; tile++) { + const b = current[tile]; + if (b === null) continue; + if (b.type === "cooler" || isReactor(b)) support.push(tile); + } + + for (const tile of support) { + const held = current[tile]; + if (held === null) continue; + + current[tile] = null; + const trial = simulateIsland(current, ctx); + + // Power alone is not enough to accept a removal. Dropping a reactor can + // starve one generator to zero while handing its heat to another that had + // spare capacity, leaving total power unchanged but stranding an idle + // generator on the board. + if ( + trial.totalPower >= bestPower - EPS && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + bestPower = trial.totalPower; + rows = trial.placements; + } else { + put(current, ctx, tile, held); + } + } + + return { rows, power: bestPower }; +} + +export function islandProgress( + placement: Placement, + ctx: IslandContext, +): IslandProgress { + const pruned = pruneDeadWeight(placement, ctx); + return { + placements: toPlacedBuildings(pruned.rows), + powerOutput: pruned.power, + }; +} diff --git a/packages/solver/src/solver/search/rightSize.ts b/packages/solver/src/solver/search/rightSize.ts new file mode 100644 index 0000000..e5e5cc8 --- /dev/null +++ b/packages/solver/src/solver/search/rightSize.ts @@ -0,0 +1,216 @@ +import type { IslandContext } from "../context"; +import { wasteIsCovered } from "../physics"; +import { type SimPlacedBuilding, simulateIsland } from "../simulate"; +import type { EffectiveBuilding, Placement } from "../types"; +import { emptyPlacement, isReactor, put } from "./placement"; +import { anyOfflineProducer, planHolds } from "./stability"; + +/** + * Right-sizing the finished layout: every building swapped for the smallest + * tier that still carries the load the layout gives it. + */ + +/** + * The roles a finished layout can be re-tiered in. A direct producer always + * runs flat out on its own, so there is no slack in one to give back — a + * smaller tier is simply less power, and the power guard below would reject it + * anyway. The other three are sized against a load the finished layout fixes, + * and any capacity above that load is money spent on a building that never + * uses it. + */ +export const DOWNGRADE_ROLES: ReadonlySet = new Set([ + "cooler", + "reactor", + "generator", +]); + +/** + * True when `capacity` meets `load` at the game's own relative tolerance. + * + * This is `wasteIsCovered`, whose parameters are named for the cooling case it + * was written for; the tolerance has to be relative here for the same reason it + * does there — at 1e18 one ULP is already ~1e2, so an absolute epsilon would + * reject a tier that is exactly big enough. + */ +export function covers(capacity: number, load: number): boolean { + return wasteIsCovered(load, capacity); +} + +/** + * What a placed building is actually doing, in the units its tier is sized in. + * + * Cooling sent for a cooler, heat sent for a reactor, heat absorbed for a + * generator — the figure a replacement tier has to be able to match for the + * rest of the layout to keep behaving the same way. + */ +export function tileLoad( + building: EffectiveBuilding, + row: SimPlacedBuilding, +): number { + if (building.type === "cooler") return row.coolingProvided; + if (isReactor(building)) return row.heatProduced; + return row.heatConsumed; +} + +/** + * The capacity `candidate` would actually run at on `tile` in this layout — + * `tileLoad`'s units, so the two can be compared. + * + * `ctx.rate` answers this for every rule but one: a role isolation multiplier is + * a function of what a tile's NEIGHBOURS are, so `rate` is the identity under it + * and `simulateIsland` resolves it per layout through `rateIsolated` instead. + * That leaves right-sizing measuring a load rated by `isolated` against an + * unrated capacity: an isolated generator authored 320, rated 1280 (at the + * `isolated` the rule shipped with) and absorbing 300, was never offered the + * authored 120 tier that covers it at its own rating of 480, so the pass left + * 980 of intake nobody pays it to have — which is exactly the money it + * exists to hand back. In the other direction the plain figure is over-generous, + * and there the re-simulation below catches it, so only the waste escaped. + * + * The neighbour scan mirrors `simulateIsland`'s: it reads what a neighbour *is*, + * which the swap cannot change — the ladder is one role deep, so a re-tiered + * generator is still a generator and every tile's crowding survives the pass. + */ +function ratedCapacity( + ctx: IslandContext, + placement: Placement, + tile: number, + candidate: EffectiveBuilding, +): number { + const isolation = ctx.isolation; + if (isolation === null || candidate.type !== isolation.role) { + return candidate.effectiveValue; + } + + const neighbors = ctx.neighbors[tile]; + let crowded = false; + for (let k = 0; k < neighbors.length; k++) { + const other = placement[neighbors[k]]; + if (other !== null && other.type === isolation.role) { + crowded = true; + break; + } + } + return ctx.rateIsolated(candidate, crowded).effectiveValue; +} + +/** Each downgradable role's roster entries, smallest capacity first. */ +export function downgradeTiers( + effectiveBuildings: EffectiveBuilding[], +): Map { + const tiers = new Map(); + for (const role of DOWNGRADE_ROLES) { + tiers.set( + role, + effectiveBuildings + .filter((b) => b.type === role) + .sort((a, b) => a.effectiveValue - b.effectiveValue), + ); + } + return tiers; +} + +/** + * Final pass: replace every building with the smallest tier that still does the + * job the finished layout gives it. Port of `_downgrade_oversized`. + * + * The search maximizes power, and power cannot tell a cooler running flat out + * from one running at 2%: both keep the same generators online, so both score + * identically and the search has no reason to prefer either. It therefore + * leaves the roster's top tier on tiles that need a twentieth of it, which + * costs the player real money for capacity that never runs. This is why the + * pass belongs after the search rather than inside it — only once the layout + * stops moving is the load on each tile settled enough to size against. + * + * Covering the tile's current load is what makes a candidate plausible, not + * what makes it safe: shrinking a supplier changes the FairShare split, and the + * split is what decides which producers clear their cooling. So every swap is + * re-simulated and accepted only under the test `pruneDeadWeight` applies to a + * removal — no power lost against the layout handed in, and no producer left + * offline. Power is defended against the figure this pass started from rather + * than against the running total, so a sequence of swaps cannot drift down one + * tolerance at a time. + * + * The sweep repeats to a fixpoint: downgrading a generator changes the waste + * its cooler has to absorb, which can free a cooler this sweep already walked + * past. It terminates because every accepted swap strictly lowers one tile's + * capacity and the ladders are finite. + * + * Tiles are visited in ascending index order, which is the game's own spatial + * order — see `context.ts`. + */ +export function downgradeOversized( + rows: SimPlacedBuilding[], + power: number, + effectiveBuildings: EffectiveBuilding[], + ctx: IslandContext, +): { rows: SimPlacedBuilding[]; power: number } { + if (rows.length === 0) return { rows, power }; + + const byId = new Map(effectiveBuildings.map((b) => [b.id, b])); + const current = emptyPlacement(ctx.n); + const occupied: number[] = []; + for (const row of rows) { + const building = byId.get(row.buildingId); + // Everything the search places comes from this roster; a layout holding + // anything else is not one this pass can reason about. + if (building === undefined) return { rows, power }; + put(current, ctx, row.idx, building); + occupied.push(row.idx); + } + occupied.sort((a, b) => a - b); + + const tiers = downgradeTiers(effectiveBuildings); + const rowAt = new Map(rows.map((r) => [r.idx, r])); + const floor = power; + + for (let changed = true; changed;) { + changed = false; + + for (const tile of occupied) { + const building = current[tile]!; + const ladder = tiers.get(building.type); + if (ladder === undefined || ladder.length === 0) continue; + + const load = tileLoad(building, rowAt.get(tile)!); + + for (const candidate of ladder) { + // Rated for this tile before it is compared to anything: `building` is + // what the tile is running and `load` is what the layout measured it + // doing, both in this tile's units, and the ladder is the plain roster. + // Ascending order survives the rating, since one tile scales every + // candidate by the same factor. + const rated = ctx.rate(tile, candidate); + // Ascending, so nothing smaller is left to try. Both sides are `rate`'s + // units, and one tile scales every candidate by the same factor, so the + // ladder's order survives whichever units this is asked in. + if (rated.effectiveValue >= building.effectiveValue) break; + // Covering the load is asked in the layout's units, which is `rate`'s + // plus whatever `simulateIsland` resolves per layout — see + // `ratedCapacity`. `load` came off a row it measured, so this is the one + // comparison here that cannot be made in the roster's units. + if (!covers(ratedCapacity(ctx, current, tile, rated), load)) continue; + + put(current, ctx, tile, rated); + const trial = simulateIsland(current, ctx); + + if ( + covers(trial.totalPower, floor) && + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) + ) { + power = trial.totalPower; + rows = trial.placements; + rowAt.clear(); + for (const r of rows) rowAt.set(r.idx, r); + changed = true; + break; + } + + put(current, ctx, tile, building); + } + } + } + + return { rows, power }; +} diff --git a/packages/solver/src/solver/search/seed.ts b/packages/solver/src/solver/search/seed.ts new file mode 100644 index 0000000..6e30108 --- /dev/null +++ b/packages/solver/src/solver/search/seed.ts @@ -0,0 +1,896 @@ +import { CHEBYSHEV_DIRECTIONS, EPS } from "../constants"; +import type { IslandContext } from "../context"; +import { + generatorPowerAndWaste, + generatorWasteRatio, + wasteIsCovered, +} from "../physics"; +import { Rng } from "../rng"; +import { simulateIsland } from "../simulate"; +import type { EffectiveBuilding, Placement } from "../types"; +import { + availableNeighbors, + chebyshev, + emptyPlacement, + put, +} from "./placement"; + +/** + * Seed construction: self-sufficient hubs claimed greedily by power per tile, + * in three tile orders, the strongest of which starts the search. + */ + +const MAX_TOP_REACTOR_COPIES = 3; +const MAX_SECOND_REACTOR_COPIES = 3; +/** + * The cooling a pooled seed sizes its hubs against: cooling from nowhere, so + * that no hub ever needs a cooler beside it. It has to be `Infinity` and not + * merely large — a fit counts `ceil(waste / cooling)` coolers, and any finite + * figure makes that one, not none. `snapToAuthoredPrecision` passes it through + * and the pool's `factor` compares with `<`, so the scratch simulation the + * shared fit runs against it is sound. Never written onto a real placement. + */ +const POOL_COOLER_VALUE = Infinity; + +/** Scratch for `generatorPowerAndWaste`, reused for the same reason as in `simulate.ts`. */ +const conversion = { power: 0.0, waste: 0.0 }; + +interface HubFit { + generator: EffectiveBuilding; + reactor: EffectiveBuilding; + r: number; + c: number; + power: number; + /** The waste the hub makes at that fill — what a pooled seed charges for. */ + waste: number; +} + +function bestHubFit( + availableSlots: number, + generators: EffectiveBuilding[], + reactors: EffectiveBuilding[], + cooler: EffectiveBuilding, +): HubFit | null { + let best: HubFit | null = null; + for (const generator of generators) { + for (const reactor of reactors) { + const maxR = Math.min( + availableSlots, + Math.ceil(generator.effectiveValue / reactor.effectiveValue), + ); + for (let r = 1; r <= maxR; r++) { + const hIn = Math.min( + generator.effectiveValue, + r * reactor.effectiveValue, + ); + generatorPowerAndWaste(generator, hIn, conversion); + const waste = conversion.waste; + const c = + cooler.effectiveValue > 0 + ? Math.ceil(waste / cooler.effectiveValue) + : 0; + if (r + c <= availableSlots) { + const power = conversion.power; + if (best === null || power > best.power) { + best = { generator, reactor, r, c, power, waste }; + } + } + if (hIn >= generator.effectiveValue - EPS) break; + } + } + } + return best; +} + +interface DpFit { + dp: EffectiveBuilding; + c: number; + power: number; + waste: number; +} + +function bestDpFit( + availableSlots: number, + directProducers: EffectiveBuilding[], + cooler: EffectiveBuilding, +): DpFit | null { + let best: DpFit | null = null; + for (const dp of directProducers) { + const waste = dp.waste; + const c = + cooler.effectiveValue > 0 ? Math.ceil(waste / cooler.effectiveValue) : 0; + if (c > availableSlots) continue; + const power = dp.energy; + if (best === null || power > best.power) best = { dp, c, power, waste }; + } + return best; +} + +/** `itertools.combinations` over a list, in the same lexicographic order. */ +function* combinations(pool: number[], k: number): Generator { + const n = pool.length; + if (k > n || k < 0) return; + const idx = new Array(k); + for (let i = 0; i < k; i++) idx[i] = i; + for (;;) { + const out = new Array(k); + for (let i = 0; i < k; i++) out[i] = pool[idx[i]]; + yield out; + + let i = k - 1; + while (i >= 0 && idx[i] === i + n - k) i--; + if (i < 0) return; + idx[i]++; + for (let j = i + 1; j < k; j++) idx[j] = idx[j - 1] + 1; + } +} + +interface SharedFit { + power: number; + reactorTiles: number[]; + reactorTiers: EffectiveBuilding[]; + genPositions: number[]; + coolerPositions: number[]; + waste: number; +} + +/** + * A multi-generator arrangement around one reactor tile, for rosters where a + * single top-tier reactor overfills a generator: the surplus heat is only + * usable if two or more generators share that reactor, which the plain hub fit + * can never express. + */ +function bestSharedReactorFit( + tile: number, + neighbors: number[], + reactors: EffectiveBuilding[], + generator: EffectiveBuilding, + cooler: EffectiveBuilding, + ctx: IslandContext, + scratch: Placement, + pooled = false, +): SharedFit | null { + if (reactors.length === 0) return null; + const topReactor = reactors[0]; + const secondReactor = reactors.length > 1 ? reactors[1] : null; + + // Under a pool `cooler` is the phantom and `c` is 0, so the arrangement is + // simulated with no cooler beside it — and a coolerless board is offline. + // The pool is stood in for by the phantom on any tile outside the + // neighbourhood; where the island has none, the arrangement cannot be + // measured and is not offered. + let poolTile = -1; + if (pooled) { + const local = new Set(neighbors); + for (let t = 0; t < ctx.n; t++) { + if (t !== tile && !local.has(t)) { + poolTile = t; + break; + } + } + if (poolTile < 0) return null; + } + + const connectivity = new Map(); + for (const n of neighbors) { + let count = 0; + for (const m of neighbors) { + if (m !== n && chebyshev(ctx, n, m) <= 1) count++; + } + connectivity.set(n, count); + } + // Ascending tile index is ascending spatial key, so this is + // `(connectivity, spatial_key)` exactly. + const byConnectivityAsc = [...neighbors].sort( + (a, b) => connectivity.get(a)! - connectivity.get(b)! || a - b, + ); + + let best: SharedFit | null = null; + const maxK1 = Math.min(MAX_TOP_REACTOR_COPIES, 1 + neighbors.length); + + for (let k1 = 1; k1 <= maxK1; k1++) { + const extraTopNeeded = k1 - 1; + const maxK2 = + secondReactor !== null + ? Math.min(MAX_SECOND_REACTOR_COPIES, neighbors.length - extraTopNeeded) + : 0; + + for (let k2 = 0; k2 <= maxK2; k2++) { + const nExtraReactors = extraTopNeeded + k2; + if (nExtraReactors > neighbors.length) continue; + + let totalValue = k1 * topReactor.effectiveValue; + if (k2 && secondReactor) totalValue += k2 * secondReactor.effectiveValue; + + const g = Math.ceil(totalValue / generator.effectiveValue); + if (g < 2 || nExtraReactors + g + 1 > neighbors.length) continue; + + const absorbed = Math.min(totalValue, g * generator.effectiveValue); + const wasteTotal = absorbed * generatorWasteRatio(generator); + const c = + cooler.effectiveValue > 0 + ? Math.ceil(wasteTotal / cooler.effectiveValue) + : 0; + if (nExtraReactors + g + c > neighbors.length) continue; + + const extraPositions = byConnectivityAsc.slice(0, nExtraReactors); + const extraTopPositions = extraPositions.slice(0, extraTopNeeded); + const extraSecondPositions = extraPositions.slice( + extraTopNeeded, + nExtraReactors, + ); + const extraSet = new Set(extraPositions); + const pool = neighbors.filter((n) => !extraSet.has(n)); + + for (const genPositions of combinations(pool, g)) { + const genSet = new Set(genPositions); + const leftover = pool.filter((n) => !genSet.has(n)); + leftover.sort((a, b) => { + let adjA = 0; + let adjB = 0; + for (const gpos of genPositions) { + if (chebyshev(ctx, a, gpos) <= 1) adjA++; + if (chebyshev(ctx, b, gpos) <= 1) adjB++; + } + return adjB - adjA || a - b; + }); + const coolerPositions = leftover.slice(0, c); + + const touched: number[] = []; + put(scratch, ctx, tile, topReactor); + touched.push(tile); + for (const n of extraTopPositions) { + put(scratch, ctx, n, topReactor); + touched.push(n); + } + for (const n of extraSecondPositions) { + put(scratch, ctx, n, secondReactor); + touched.push(n); + } + for (const n of genPositions) { + put(scratch, ctx, n, generator); + touched.push(n); + } + for (const n of coolerPositions) { + put(scratch, ctx, n, cooler); + touched.push(n); + } + if (pooled) { + put(scratch, ctx, poolTile, cooler); + touched.push(poolTile); + } + + const { totalPower } = simulateIsland(scratch, ctx); + for (const n of touched) scratch[n] = null; + + if (totalPower > EPS && (best === null || totalPower > best.power)) { + const reactorTiles = [ + tile, + ...extraTopPositions, + ...extraSecondPositions, + ]; + const reactorTiers: EffectiveBuilding[] = [ + topReactor, + ...extraTopPositions.map(() => topReactor), + ...extraSecondPositions.map(() => secondReactor!), + ]; + best = { + power: totalPower, + reactorTiles, + reactorTiers, + genPositions, + coolerPositions, + waste: wasteTotal, + }; + } + } + } + } + + return best; +} + +/** A `SharedFit` with every tile spelled as a direction from its centre. */ +interface RelativeSharedFit { + power: number; + waste: number; + /** Direction index per reactor, or -1 for the centre tile itself. */ + reactorDirections: number[]; + reactorTiers: EffectiveBuilding[]; + genDirections: number[]; + coolerDirections: number[]; +} + +/** + * The shape of a tile's free neighbourhood: a bit per `CHEBYSHEV_DIRECTIONS` + * entry that holds a free tile, with the maps between tile and direction. + */ +function neighbourhoodShape( + ctx: IslandContext, + tile: number, + neighbors: number[], +): { + mask: number; + directionOf: Map; + tileAt: Int32Array; +} { + let mask = 0; + const directionOf = new Map(); + const tileAt = new Int32Array(CHEBYSHEV_DIRECTIONS.length).fill(-1); + for (const n of neighbors) { + const dx = ctx.xs[n] - ctx.xs[tile]; + const dy = ctx.ys[n] - ctx.ys[tile]; + for (let d = 0; d < CHEBYSHEV_DIRECTIONS.length; d++) { + if ( + CHEBYSHEV_DIRECTIONS[d][0] === dx && + CHEBYSHEV_DIRECTIONS[d][1] === dy + ) { + mask |= 1 << d; + directionOf.set(n, d); + tileAt[d] = n; + break; + } + } + } + return { mask, directionOf, tileAt }; +} + +function toRelativeFit( + fit: SharedFit, + directionOf: Map, +): RelativeSharedFit { + const direction = (t: number): number => directionOf.get(t) ?? -1; + return { + power: fit.power, + waste: fit.waste, + reactorDirections: fit.reactorTiles.map(direction), + reactorTiers: fit.reactorTiers, + genDirections: fit.genPositions.map(direction), + coolerDirections: fit.coolerPositions.map(direction), + }; +} + +function fromRelativeFit( + fit: RelativeSharedFit, + tile: number, + tileAt: Int32Array, +): SharedFit { + const at = (d: number): number => (d < 0 ? tile : tileAt[d]); + return { + power: fit.power, + waste: fit.waste, + reactorTiles: fit.reactorDirections.map(at), + reactorTiers: fit.reactorTiers, + genPositions: fit.genDirections.map(at), + coolerPositions: fit.coolerDirections.map(at), + }; +} + +interface CandidateEntry { + /** + * Power PER TILE CLAIMED, which is what candidates are ranked by — not raw + * power. A candidate that spends more tiles (e.g. a multi-reactor "shared" + * setup) can have higher raw power than a compact one-reactor hub while + * actually being a worse use of the island's limited tiles: it wins a single + * greedy round yet leaves the *rest* of the island worse off, since those + * extra tiles are no longer available for other, more efficient hubs. + * Ranking by power/tile keeps the greedy choice locally optimal in the + * currency that is actually scarce. + */ + powerPerTile: number; + kind: "hub" | "dp" | "shared"; + tile: number; + neighbors: number[]; + hub?: HubFit; + dp?: DpFit; + shared?: SharedFit; +} + +function constructSeed( + buildableOrder: number[], + ctx: IslandContext, + reactors: EffectiveBuilding[], + generators: EffectiveBuilding[], + coolers: EffectiveBuilding[], + directProducers: EffectiveBuilding[], + deadlineMs: number, + pooled = false, + sizeByIsolation = true, +): Placement { + const placement = emptyPlacement(ctx.n); + const available = new Uint8Array(ctx.n); + for (const tile of buildableOrder) available[tile] = 1; + let numAvailable = buildableOrder.length; + + const topReactor = reactors.length > 0 ? reactors[0] : null; + const topGenerator = generators.length > 0 ? generators[0] : null; + const topCooler = coolers.length > 0 ? coolers[0] : null; + + // Under a generator isolation a hub is sized with its generator at the + // ISOLATED rating, and no hub's generator is put beside another. Sized from + // the plain roster, a full-unlock hub is one ecto reactor filling one + // generator and the "shared" fit stands three generators round one reactor + // — every one of them crowded, on a board where an isolated generator takes + // two reactors and makes four times the power. That seed scored 32% of the + // Singularity bound on Event Horizon where the base seed scores 60% of its + // own (`npm run profile`), and the first repair had to double it. The fit + // is sized from the rated figure and the plain entry is what is written, so + // `simulateIsland` rates the tile from its neighbours as it always does. + // Off under every other rule, so the fixtures reproduce byte for byte. + // + // Measured with the survey (`--anomaly singularity_isolation --seed 1`): + // after the first repair the seed reads 78-88% of the bound on the eight + // largest islands against the plain one's 50-64%. At 15s x 5 runs that is + // a wash, -1.5% to +2.2% per map and every map inside its band; at 5s x 3 + // runs, where the budget binds, Event Horizon gains 14% and Shadowspire + // 18%, four maps 2-4%, Ash Bay and Magma Rift read 4% and 1% lower inside + // their bands. With generator7 at tier 3 — the roster where the bonus is + // worth most — every map is inside 0.8%. + const isolation = ctx.isolation; + const isolatedSeed = + sizeByIsolation && + isolation !== null && + isolation.role === "generator" && + topGenerator !== null && + ctx.rateIsolated(topGenerator, true).effectiveValue < + ctx.rateIsolated(topGenerator, false).effectiveValue; + const fitGenerators = isolatedSeed + ? generators.map((g) => ctx.rateIsolated(g, false)) + : generators; + const fitGenerator = fitGenerators.length > 0 ? fitGenerators[0] : null; + const plainOf = new Map(); + for (let i = 0; i < generators.length; i++) + plainOf.set(fitGenerators[i], generators[i]); + /** Whether a generator written on `tile` would crowd, or be crowded by, one. */ + const besideGenerator = (tile: number): boolean => { + const nb = ctx.neighbors[tile]; + for (let k = 0; k < nb.length; k++) { + const b = placement[nb[k]]; + if (b !== null && b.type === "generator") return true; + } + return false; + }; + + const canHub = + topReactor !== null && topGenerator !== null && topCooler !== null; + const canDp = directProducers.length > 0 && topCooler !== null; + const trySharedReactor = + canHub && + Math.ceil(topReactor!.effectiveValue / fitGenerator!.effectiveValue) >= 2; + + // Under a cooling pool a hub is sized with a phantom cooler that covers any + // waste from nowhere, so every neighbour slot goes to reactors and no cooler + // is written beside a hub. The cooling it really needs is charged as a + // board-wide debt instead: counted into each candidate's rank as the + // fractional tiles it will cost, reserved out of the free tiles so a hub can + // never crowd out the coolers that keep it online, and paid off after the + // hubs are down onto the least-connected free tiles — the scraps no hub can + // use, which under the pool are worth exactly what a well-connected tile is. + const fitCooler: EffectiveBuilding | null = + pooled && topCooler !== null + ? { ...topCooler, effectiveValue: POOL_COOLER_VALUE } + : topCooler; + const coolerValue = + topCooler !== null ? ctx.rateRole(topCooler).effectiveValue : 0; + /** Coolers the pool needs for `waste`, at the rated figure. */ + const coolersFor = (waste: number): number => + waste > EPS && coolerValue > 0 ? Math.ceil(waste / coolerValue) : 0; + /** What the board's producers are owed so far, from the simulation. */ + let debt = 0; + const actualWaste = (): number => { + const rows = simulateIsland(placement, ctx, true).placements; + let sum = 0; + for (let i = 0; i < rows.length; i++) sum += rows[i].wasteHeatGenerated; + return sum; + }; + /** Whether claiming `tiles` still leaves room for the coolers `waste` needs. */ + const reserves = (tiles: number, waste: number): boolean => + !pooled || numAvailable - tiles >= coolersFor(debt + waste); + /** Tiles a candidate charges for: its own, plus the pool's share under one. */ + const cost = (tiles: number, waste: number): number => + pooled ? tiles + waste / coolerValue : tiles; + /** The tiles each claim took, newest last, for the pooled backstop. */ + const groups: number[][] = []; + + const hubFitCache = new Map(); + const dpFitCache = new Map(); + const sharedScratch = emptyPlacement(ctx.n); + + // A shared fit is simulated on an otherwise empty board, so it is a function + // of the neighbourhood's *shape* alone — which of the eight directions hold a + // free tile — as long as every tile rates a building the same: the tile + // order the distribution runs in is the spatial order, which the same shape + // reproduces wherever it sits. That is true under a uniform rating and under + // a role scale, which rates every tile alike; a terrain bonus is per tile and + // keeps the uncached path. Memoised by that shape, because this is the whole + // cost of the seed: it used to re-simulate every generator combination for + // every stale tile on every round, 0.5-0.8s on the largest shipped islands. + const sharedFitByShape = + ctx.uniformRating || ctx.anomaly.rule === "shared_cooling" + ? new Map() + : null; + const sharedFit = (tile: number, neighbors: number[]): SharedFit | null => { + if (sharedFitByShape === null) { + return bestSharedReactorFit( + tile, + neighbors, + reactors, + fitGenerator!, + fitCooler!, + ctx, + sharedScratch, + pooled, + ); + } + const shape = neighbourhoodShape(ctx, tile, neighbors); + let relative = sharedFitByShape.get(shape.mask); + if (relative === undefined) { + const fit = bestSharedReactorFit( + tile, + neighbors, + reactors, + fitGenerator!, + fitCooler!, + ctx, + sharedScratch, + pooled, + ); + relative = fit === null ? null : toRelativeFit(fit, shape.directionOf); + sharedFitByShape.set(shape.mask, relative); + } + return relative === null + ? null + : fromRelativeFit(relative, tile, shape.tileAt); + }; + + const hubFit = (slots: number): HubFit | null => { + let fit = hubFitCache.get(slots); + if (fit === undefined) { + fit = canHub + ? bestHubFit(slots, fitGenerators, reactors, fitCooler!) + : null; + hubFitCache.set(slots, fit); + } + return fit; + }; + + const dpFit = (slots: number): DpFit | null => { + let fit = dpFitCache.get(slots); + if (fit === undefined) { + fit = canDp ? bestDpFit(slots, directProducers, fitCooler!) : null; + dpFitCache.set(slots, fit); + } + return fit; + }; + + const candidateCache = new Array(ctx.n).fill(null); + const stale = new Uint8Array(ctx.n); + for (const tile of buildableOrder) stale[tile] = 1; + + while (numAvailable > 0) { + if (performance.now() >= deadlineMs) break; + + const staleList = buildableOrder.filter((tile) => stale[tile] === 1); + for (const tile of staleList) { + if (performance.now() >= deadlineMs) break; + + const neighbors = availableNeighbors(tile, available, ctx); + let entry: CandidateEntry | null = null; + const consider = (candidate: CandidateEntry) => { + if (entry === null || candidate.powerPerTile > entry.powerPerTile) + entry = candidate; + }; + // A hub's generator stands on `tile`; beside a placed generator it + // would be crowded, and crowd that one. Only the stale tiles are + // re-read here and a claim marks every tile beside it stale, so this + // sees each generator as it lands. + const crowds = isolatedSeed && besideGenerator(tile); + + // Under a pool the fit shrinks until its reactors and its coolers both + // fit in what is left; under the base rules the first fit is the one. + let hub = crowds ? null : hubFit(neighbors.length); + while (hub !== null && !reserves(1 + hub.r, hub.waste)) { + hub = hub.r > 1 ? hubFit(hub.r - 1) : null; + } + if (hub !== null) { + const tilesUsed = 1 + hub.r + hub.c; + if (hub.power > EPS && tilesUsed > 0) { + consider({ + powerPerTile: hub.power / cost(tilesUsed, hub.waste), + kind: "hub", + tile, + neighbors, + hub, + }); + } + } + + const dp = dpFit(neighbors.length); + if (dp !== null && reserves(1, dp.waste)) { + const tilesUsed = 1 + dp.c; + if (dp.power > EPS && tilesUsed > 0) { + consider({ + powerPerTile: dp.power / cost(tilesUsed, dp.waste), + kind: "dp", + tile, + neighbors, + dp, + }); + } + } + + if (trySharedReactor && neighbors.length >= 3) { + const shared = sharedFit(tile, neighbors); + if ( + shared !== null && + !(isolatedSeed && shared.genPositions.some(besideGenerator)) + ) { + const tilesUsed = + shared.reactorTiles.length + + shared.genPositions.length + + shared.coolerPositions.length; + if (tilesUsed > 0 && reserves(tilesUsed, shared.waste)) { + consider({ + powerPerTile: shared.power / cost(tilesUsed, shared.waste), + kind: "shared", + tile, + neighbors, + shared, + }); + } + } + } + + candidateCache[tile] = entry; + } + + stale.fill(0); + + let best: CandidateEntry | null = null; + for (const tile of buildableOrder) { + if (!available[tile]) continue; + const entry = candidateCache[tile]; + if ( + entry !== null && + (best === null || entry.powerPerTile > best.powerPerTile) + ) { + best = entry; + } + } + if (best === null) break; + + if (pooled) { + // The debt has grown since a candidate untouched by the last claim was + // sized, so it may no longer leave room for its coolers. Re-size every + // open tile against the debt as it stands: a candidate sized against the + // current debt passes this at once, so the loop cannot spin. + const tiles = + best.kind === "hub" + ? 1 + best.hub!.r + : best.kind === "dp" + ? 1 + : best.shared!.reactorTiles.length + + best.shared!.genPositions.length; + const waste = + best.kind === "hub" + ? best.hub!.waste + : best.kind === "dp" + ? best.dp!.waste + : best.shared!.waste; + if (!reserves(tiles, waste)) { + for (const tile of buildableOrder) if (available[tile]) stale[tile] = 1; + continue; + } + } + + const claimed: number[] = []; + if (best.kind === "hub") { + const { generator, reactor, r, c } = best.hub!; + put(placement, ctx, best.tile, plainOf.get(generator) ?? generator); + claimed.push(best.tile); + for (let i = 0; i < r; i++) { + put(placement, ctx, best.neighbors[i], reactor); + claimed.push(best.neighbors[i]); + } + for (let i = r; i < r + c; i++) { + put(placement, ctx, best.neighbors[i], topCooler); + claimed.push(best.neighbors[i]); + } + } else if (best.kind === "shared") { + const shared = best.shared!; + for (let i = 0; i < shared.reactorTiles.length; i++) { + put(placement, ctx, shared.reactorTiles[i], shared.reactorTiers[i]); + claimed.push(shared.reactorTiles[i]); + } + for (const n of shared.genPositions) { + put(placement, ctx, n, topGenerator); + claimed.push(n); + } + for (const n of shared.coolerPositions) { + put(placement, ctx, n, topCooler); + claimed.push(n); + } + } else { + const { dp, c } = best.dp!; + put(placement, ctx, best.tile, dp); + claimed.push(best.tile); + for (let i = 0; i < c; i++) { + put(placement, ctx, best.neighbors[i], topCooler); + claimed.push(best.neighbors[i]); + } + } + + for (const tile of claimed) { + if (available[tile]) { + available[tile] = 0; + numAvailable--; + } + candidateCache[tile] = null; + } + for (const tile of claimed) { + const nb = ctx.neighbors[tile]; + for (let k = 0; k < nb.length; k++) { + if (available[nb[k]]) stale[nb[k]] = 1; + } + } + if (pooled) { + groups.push(claimed); + // The simulated figure rather than the sum of the fits' estimates: two + // hubs down beside each other share reactors, and a generator fed by a + // neighbour's surplus makes waste no fit counted. + debt = actualWaste(); + } + } + + if (pooled && topCooler !== null) { + // Pay the debt off, least-connected tile first: a one-tile scrap holds a + // cooler as well as any tile does, and nothing else can use it. Ascending + // index breaks ties so the fill is a function of the board alone. + const free = buildableOrder + .filter((tile) => available[tile] === 1) + .sort( + (a, b) => ctx.neighbors[a].length - ctx.neighbors[b].length || a - b, + ); + let cooling = 0; + let next = 0; + const payDown = () => { + while (next < free.length && !wasteIsCovered(debt, cooling)) { + const tile = free[next++]; + put(placement, ctx, tile, topCooler); + cooling += placement[tile]!.effectiveValue; + } + }; + payDown(); + + // A short pool is an all-offline board, and `stabilize` would then delete + // every producer. So the board has to come up here: one more cooler while + // there is a tile for it, and failing that the newest hub is taken back + // and its tiles spent on the pool instead. + while (debt > EPS && simulateIsland(placement, ctx).totalPower <= EPS) { + if (next < free.length) { + const tile = free[next++]; + put(placement, ctx, tile, topCooler); + cooling += placement[tile]!.effectiveValue; + continue; + } + const group = groups.pop(); + if (group === undefined) break; + for (const tile of group) placement[tile] = null; + for (const tile of group) free.push(tile); + debt = actualWaste(); + payDown(); + } + } + + return placement; +} + +/** + * `sizeByIsolation` is `constructSeed`'s: false builds the plain-sized seed + * under a role isolation, which `solveIsland` compares against the sized one. + * It means nothing under any other rule. + */ +export function constructMultiStartSeed( + ctx: IslandContext, + reactors: EffectiveBuilding[], + generators: EffectiveBuilding[], + coolers: EffectiveBuilding[], + directProducers: EffectiveBuilding[], + deadlineMs: number, + sizeByIsolation = true, +): Placement { + const buildable = Array.from(ctx.tiles); + + const passes = ( + pooled: boolean, + sizeByIsolation = true, + ): { seed: Placement; power: number } => { + let bestSeed = constructSeed( + buildable, + ctx, + reactors, + generators, + coolers, + directProducers, + deadlineMs, + pooled, + sizeByIsolation, + ); + let bestPower = simulateIsland(bestSeed, ctx).totalPower; + + if (bestPower <= EPS) { + // The deadline elapsed mid-construction and left a seed that powers + // nothing. One full greedy pass costs about 25ms on the largest island + // in the game (`npm run profile`), so finish it: refining a degenerate + // seed wastes the whole budget and can return an empty layout for a + // solvable island. + bestSeed = constructSeed( + buildable, + ctx, + reactors, + generators, + coolers, + directProducers, + performance.now() + 1000, + pooled, + sizeByIsolation, + ); + bestPower = simulateIsland(bestSeed, ctx).totalPower; + } + + if (performance.now() >= deadlineMs - 50 || buildable.length < 6) + return { seed: bestSeed, power: bestPower }; + + const reversed = [...buildable].reverse(); + const seedRev = constructSeed( + reversed, + ctx, + reactors, + generators, + coolers, + directProducers, + deadlineMs, + pooled, + sizeByIsolation, + ); + const powerRev = simulateIsland(seedRev, ctx).totalPower; + if (powerRev > bestPower + EPS) { + bestPower = powerRev; + bestSeed = seedRev; + } + + if (performance.now() < deadlineMs - 50) { + const rng = new Rng(42); + const shuffled = [...buildable]; + rng.shuffle(shuffled); + const seedRand = constructSeed( + shuffled, + ctx, + reactors, + generators, + coolers, + directProducers, + deadlineMs, + pooled, + sizeByIsolation, + ); + const powerRand = simulateIsland(seedRand, ctx).totalPower; + if (powerRand > bestPower + EPS) { + bestPower = powerRand; + bestSeed = seedRand; + } + } + + return { seed: bestSeed, power: bestPower }; + }; + + const local = passes(false, sizeByIsolation); + if (ctx.anomaly.rule !== "shared_cooling") return local.seed; + + // Under a pool the same three orders are run engine-first as well, and the + // strongest seed of the six starts the search. The local passes stay in: + // they are what the search started from before the pooled ones existed, and + // a pooled seed that comes back weaker is not an improvement. + const pooled = passes(true); + return pooled.power > local.power + EPS ? pooled.seed : local.seed; +} diff --git a/packages/solver/src/solver/search/stability.ts b/packages/solver/src/solver/search/stability.ts new file mode 100644 index 0000000..7a0dd32 --- /dev/null +++ b/packages/solver/src/solver/search/stability.ts @@ -0,0 +1,161 @@ +import { EPS } from "../constants"; +import type { IslandContext } from "../context"; +import { type SimPlacedBuilding, simulateIsland } from "../simulate"; +import type { EffectiveBuilding, Placement } from "../types"; +import { isDirectProducer } from "./placement"; + +/** + * What makes a layout an answer: every producer online, at the target and at + * every step of an upgrade plan. See the stability rule in `placementSearch.ts`. + */ + +/** + * Tiles holding a generator or direct producer that generates no power — + * either because it is overheating (waste above the cooling routed to it) or + * because it never received any heat at all. + * + * A layout containing one of these is not a valid answer: the goal is a fully + * stable grid where every producer runs, so these tiles are wasted at best and + * represent an overheating building at worst. + */ +export function offlineProducers( + placement: Placement, + rows: SimPlacedBuilding[], +): number[] { + const offline: number[] = []; + for (let i = 0; i < rows.length; i++) { + const p = rows[i]; + const building = placement[p.idx]; + if (building === null) continue; + const producer = + building.type === "generator" || isDirectProducer(building); + if (producer && p.powerGenerated <= EPS) offline.push(p.idx); + } + return offline; +} + +/** + * A supplier sending out less than the tile it stands on was rated to carry — + * the walk's cue that a generator beside it would have somewhere to put the + * rest. + * + * Against `ratedValue`, never `baseValue`: `heatProduced` is what the + * distribution actually sent, capped by the tile's RATING, so measuring it + * against the authored tier compares a scaled delivery with an unscaled ceiling + * and only calls a reactor under-fed below 1/k fill for a tile rated k — on a + * Tidal shore k is its `multiplier`, under a maxed Stellar Forge the + * research's. The move then stops firing on much of what it exists for, with + * no number anywhere disagreeing. It is a named function so that the reading + * can be pinned by a test rather than only stated here. + */ +export function isUnderFed(row: SimPlacedBuilding): boolean { + return row.heatProduced > EPS && row.heatProduced < row.ratedValue - EPS; +} + +/** Allocation-free `offlineProducers(...).length > 0`, for the search's inner loops. */ +export function anyOfflineProducer( + placement: Placement, + rows: SimPlacedBuilding[], +): boolean { + for (let i = 0; i < rows.length; i++) { + const p = rows[i]; + const building = placement[p.idx]; + if (building === null) continue; + const producer = + building.type === "generator" || isDirectProducer(building); + if (producer && p.powerGenerated <= EPS) return true; + } + return false; +} + +/** + * An upgrade plan: the rosters a layout has to stay stable under besides the + * one it is scored at — the tiers the player holds today and every step on the + * way to the target. Each step is keyed by building id, because a placement + * holds the target roster's objects and the step's own figures are found by + * id; a building the step does not have stands on its tile as nothing. + * + * A step is simulated with the rating its tiles give it (`ctx.rate`), so a + * terrain bonus or a cooling pool applies at every step exactly as it does at + * the target. + */ +interface UpgradePlan { + steps: Map[]; + /** Scratch the step layout is written into, one per island. */ + buffer: Placement; +} + +/** + * Held beside the context rather than on it, so the context type — and every + * stage that takes one — is untouched when there is no plan, which is always + * the case for the fixtures. + */ +export const upgradePlans = new WeakMap(); + +/** + * The producers that go offline under the first plan step the layout fails, or + * null when it runs at every step. Free when the island has no plan. + */ +function planFailure( + placement: Placement, + ctx: IslandContext, +): number[] | null { + const plan = upgradePlans.get(ctx); + if (plan === undefined) return null; + const buffer = plan.buffer; + for (const step of plan.steps) { + for (let t = 0; t < ctx.n; t++) { + const b = placement[t]; + const at = b === null ? undefined : step.get(b.id); + buffer[t] = at === undefined ? null : ctx.rate(t, at); + } + const sim = simulateIsland(buffer, ctx); + const offline = offlineProducers(buffer, sim.placements); + if (offline.length > 0) return offline; + } + return null; +} + +/** Whether the layout runs at every step of the island's plan. */ +export function planHolds(placement: Placement, ctx: IslandContext): boolean { + return planFailure(placement, ctx) === null; +} + +interface StableLayout { + placement: Placement; + power: number; + rows: SimPlacedBuilding[]; +} + +/** + * Drops producers that generate no power until every remaining one is online. + * + * This has to iterate rather than delete them all in one pass: removing an + * offline generator hands the heat it was absorbing to its neighbours, which + * can push THEIR waste past what their coolers cover and take them offline in + * turn. Power can legitimately fall as a result — a layout that only scores + * well by parking heat in an overheating building is not a layout we want. + */ +export function stabilize( + placement: Placement, + ctx: IslandContext, +): StableLayout { + const current = placement.slice(); + let sim = simulateIsland(current, ctx); + + for (;;) { + let offline = offlineProducers(current, sim.placements); + // Stable at the target is not enough under a plan: a producer that + // overheats at any step goes too, and so on until every step runs. + if (offline.length === 0) offline = planFailure(current, ctx) ?? []; + if (offline.length === 0) { + return { + placement: current, + power: sim.totalPower, + rows: sim.placements, + }; + } + for (const tile of offline) current[tile] = null; + sim = simulateIsland(current, ctx); + } +} diff --git a/packages/solver/tests/workerSafety.test.ts b/packages/solver/tests/workerSafety.test.ts index 5f54a44..d959dbc 100755 --- a/packages/solver/tests/workerSafety.test.ts +++ b/packages/solver/tests/workerSafety.test.ts @@ -76,15 +76,19 @@ export function violationFor(specifier: string, from: string): string | null { if (!from.startsWith("solver/")) { return null; } - if (specifier.startsWith("./")) { - return null; // within src/solver/ - } - const sibling = /^\.\.\/([^/]+)/.exec(specifier)?.[1]; - if (sibling === undefined) { + // Resolved against the importing file's own folder, so a module in a + // subfolder of src/solver/ reaching `../constants` is still within the + // engine, and one reaching `../../encoding/x` is caught like any other. + const target = posix.normalize(posix.join(posix.dirname(from), specifier)); + if (target.startsWith("../") || target === "..") { return "escapes src/ entirely"; } - if (!ALLOWED_SIBLING_FOLDERS.includes(sibling)) { - return `reaches into src/${sibling}/, which the engine may not depend on`; + const folder = target.split("/")[0]; + if (folder === "solver") { + return null; // within src/solver/ + } + if (!ALLOWED_SIBLING_FOLDERS.includes(folder)) { + return `reaches into src/${folder}/, which the engine may not depend on`; } return null; } @@ -133,6 +137,14 @@ describe("worker safety", () => { ); expect(violationFor("../utils/formatters", from)).toMatch(/src\/utils/); expect(violationFor("pixi.js", from)).toMatch(/bare package/); + // A subfolder of the engine is held to the same rule, resolved from where + // it stands rather than read off the specifier's first segment. + expect( + violationFor("../../encoding/blueprint", "solver/search/seed.ts"), + ).toMatch(/src\/encoding/); + expect(violationFor("../../../x", "solver/search/seed.ts")).toMatch( + /escapes src/, + ); // A bare specifier is refused wherever it is written, not only in solver/. expect(violationFor("pako", "encoding/blueprint.ts")).toMatch( /bare package/, @@ -142,6 +154,11 @@ describe("worker safety", () => { it("allows what each layer legitimately needs", () => { expect(violationFor("./types", "solver/placementSearch.ts")).toBeNull(); expect(violationFor("./constants", "solver/island.ts")).toBeNull(); + expect(violationFor("../constants", "solver/search/seed.ts")).toBeNull(); + expect(violationFor("./placement", "solver/search/seed.ts")).toBeNull(); + expect( + violationFor("../../data/anomalies", "solver/search/seed.ts"), + ).toBeNull(); expect( violationFor("../data/effectiveBuildings", "solver/solver.ts"), ).toBeNull();