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

@@ -85,7 +86,7 @@ The second figure is what it should actually take here. Searches run in parallel — this browser gives {poolSize} {poolSize === 1 ? "worker" : "workers"}, shared across the map's islands — - so fifty searches cost nothing like fifty times one. + so twenty-five searches cost nothing like twenty-five times one.

@@ -98,8 +99,7 @@ {#if budgetScale !== 1}

Under {configState.activeAnomaly.name} every search runs {budgetScale}× - as long. A generator's output depends on its neighbours there, so a walk - needs the extra time to find layouts that keep them apart. + as long. {attemptBudgetReason(configState.anomalyId)}

{/if} diff --git a/src/lib/components/sidebar/SolveModeSelector.svelte b/src/lib/components/sidebar/SolveModeSelector.svelte index c61222e..3cb70ad 100755 --- a/src/lib/components/sidebar/SolveModeSelector.svelte +++ b/src/lib/components/sidebar/SolveModeSelector.svelte @@ -6,14 +6,14 @@ * How the next run spends its time, offered as a choice rather than picked * for the user. * - * The search is stochastic, so one 30s walk is a single sample and ten 10s - * walks are ten — usually the better answer, and reliably the better + * The search is stochastic, so one 30s walk is a single sample and five 20s + * walks are five — usually the better answer, and reliably the better * *shortlist*, since ties found by different attempts are pooled. What it * costs is wall-clock, and how much depends on the machine: the attempts go * through the same worker pool the islands already use, so a browser * reporting eight cores overlaps most of them and a single-core one pays the * full serial price. That is the whole reason the estimate is printed here — - * "10 × 10s" is not a duration on its own. + * "5 × 20s" is not a duration on its own. * * It sits directly above the Run button rather than inside the re-run dialog * because it has to be answerable on a first run too, when no dialog opens. diff --git a/src/lib/state/CLAUDE.md b/src/lib/state/CLAUDE.md index 97b9b5b..0b4b02f 100644 --- a/src/lib/state/CLAUDE.md +++ b/src/lib/state/CLAUDE.md @@ -151,7 +151,7 @@ watchdog and the run all read the scaled mode, so none of them can disagree. Attempts are ordinary pool tasks: `attempts × islands` go into the same queue that runs one island per worker, so extra searches cost wall-clock only once the pool is full. That is why `estimatedRunMs` exists and why the control prints it — -"10 × 10s" is not a duration until you know how many workers the browser gives +"5 × 20s" is not a duration until you know how many workers the browser gives you. `estimateMakespanMs` replays the coordinator's own greedy dispatch over the task durations rather than guessing. @@ -169,12 +169,27 @@ pooled rather than discarded. Finished attempts only: a progress report is a snapshot of a search still moving, so the coordinator streams those to the screen on a separate, monotonic track and never files them as answers. +**The page gets at most one progress update per report interval, however many +workers are reporting.** Each worker reports on its own clock, so a pool of eight +handed the main thread up to eight full boards a second to rebuild, re-score and +redraw. The workers' rate does not slow with the machine and the main thread's +cost does: on a page slowed 6x that kept it 85% busy, and a main thread that +falls behind cannot read the next message either — the board stops updating, Stop +is never forwarded, and a queued task waits on a DONE nobody has read. Every +report still lands in the monotonic track; only the page's copy is coalesced, and +a pending update is cancelled when the run settles so it cannot draw over the +finished result. + **A re-run has to earn its place.** `runOptimizer({ keepBest })` captures the shortlist on screen and puts the new result back only if it scores higher; a defended layout keeps its _own_ run's duration and timestamp, because it is still that solve and the card prints both. The search is stochastic and the budget short, so a second run genuinely can come back worse — which is why the choice is -put to the user (`uiState.requestSolve`) rather than assumed. +put to the user (`uiState.requestSolve`) rather than assumed. The other answer, +discarding, deletes the layout from the screen and from storage as the run +starts (`uiState.startSolve`), not when its result lands: a layout the player +threw away should not stay on the board through the run, and a run stopped before +it reports would otherwise hand it back. Two things about "the layout on screen" are load-bearing: @@ -204,8 +219,16 @@ The list is filled from two directions: one run usually fills it on the shipped islands, and `chooseSolveVariants` pools further runs that **tie** into it (a fresh layout joins only if it clears `MIN_ALTERNATE_DISTANCE`; held layouts go in unfiltered and stay in front, so the user's pick keeps its index). A run that -beats it replaces the list; one that comes back lower is defended against. That -policy is exported and pinned by `state/solveVariants.test.ts` rather than buried +beats it replaces the list; one that comes back lower is defended against. + +Before that whole-board comparison, `composeIslandBests` takes each **island's** +best across the held and fresh lists. Islands never interact, so when each run +won some islands, a board made of every island's winner beats both. That board +stands in for the fresh run. Without the merge, a re-run that found a better +large island would win on the total and bring a worse small island with it. It +does nothing under a shared cooling pool (one island) and returns null when one +board already holds every best, leaving the plain rules above. Both policies are +exported and pinned by `state/solveVariants.test.ts` rather than buried in `runOptimizer`, because it is the whole meaning of "ten layouts at the same power". @@ -221,10 +244,15 @@ one, which is what it was. `MAX_STORED_SOLVES`) and re-hung by `restore()`, which `hydrateState()` awaits _after_ `layoutState.hydrate()` — deciding whether a stored result is still valid means reading the board it was solved against. Validity is a stored signature: -the terrain (`blueprintKey(grid)`, no placements) and the roster. Hand-placed -buildings are deliberately outside it because the solver ignores them, so -building by hand does not invalidate a solve; clearing an obstacle or changing an -unlock does. +the terrain (`blueprintKey(grid)`, no placements), the roster, the anomaly and +the research. Hand-placed buildings are deliberately outside it because the +solver ignores them, so building by hand does not invalidate a solve; clearing an +obstacle does. A mismatch on anything **but** the terrain is not a different +board, so `restore()` re-rates rather than discards: the same `#refile` a game +import uses re-tiers the shapes to the current unlocks, drops any layout using a +building no longer unlocked (the solve too, once none are left), re-scores and +re-files under the new signature. That is what lets a Setup change made with one +island on screen leave every other island's solve standing. A run can take five minutes, which is what makes losing one expensive. **Switching islands is a change of view, not a deletion**: `TemplateSelector` @@ -482,6 +510,16 @@ player owns nothing there yet. Because it replaces work, the Import dialog summarises the code first and takes two presses. The template-to-game mapping is `IslandTemplate.game` in `data/maps.ts`. +**An import keeps the solves it leaves buildable.** It changes the roster, the +research and usually the terrain, all of which are in `solveSignature()`, so +left alone `restore()` would discard every island's solve on its next open. +`solverState.carryOverSolves` runs first and decides per island: a solve goes +for an island still locked in the game or one searched under another anomaly; +a layout goes if the import put an obstacle under one of its buildings or no +longer unlocks one of them (the solve with it once none survive); the rest are +re-tiered, re-scored on the island's new board and re-filed under its new +signature, without searching again. + ### The transformer is special Two rules the other tile types do not have, both enforced in `editorState`: diff --git a/src/lib/state/layout.svelte.ts b/src/lib/state/layout.svelte.ts index 312fc62..93b3fdb 100755 --- a/src/lib/state/layout.svelte.ts +++ b/src/lib/state/layout.svelte.ts @@ -538,6 +538,21 @@ class LayoutState { this.recalculate(); } + /** + * The terrain an island would load with — its saved layout's, or its map's — + * without putting it on screen. Null for an id that names no island, or a + * board that cannot be read. + */ + async boardOf(templateId: string): Promise { + const template = this.findTemplate(templateId); + if (!template) return null; + const saved = await this.#decodeSaved( + templateId, + this.savedGrids[templateId], + ); + return (saved ?? (await this.#decodeTemplate(template)))?.grid ?? null; + } + /** * Switches templates: pristine terrain, then the user's saved layout for it * — terrain *and* placements — if they have one, landing as one write. diff --git a/src/lib/state/solveCarryOver.test.ts b/src/lib/state/solveCarryOver.test.ts new file mode 100644 index 0000000..7960fe9 --- /dev/null +++ b/src/lib/state/solveCarryOver.test.ts @@ -0,0 +1,321 @@ +/** + * What importing the game does to the solves already stored for each island. + * + * An import changes the roster, the research and usually the terrain, and all + * three are in a solve's signature — so without `carryOverSolves` every + * island's solve would be discarded the next time it was opened. These pin + * which ones survive: a solve is dropped only for a locked island, another + * anomaly, or layouts the import made unbuildable. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { + blueprintKey, + decodeBlueprint, + encodeBlueprint, + ISLAND_TEMPLATES, +} from "@reactor2/solver"; +import type { AnomalyId } from "@reactor2/solver"; +import type { OptimizationResult, PlacedBuilding, Tile } from "../types"; +import type { StoredPlacement } from "../storage/storage"; + +// The storage helpers are SSR-guarded on `typeof window`, so a test running in +// node needs both a window and a localStorage before it imports anything that +// touches them. +const store = new Map(); +(globalThis as { window?: unknown }).window = globalThis; +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => void store.set(key, String(value)), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), +}; + +const { configState } = await import("./config.svelte"); +const { layoutState } = await import("./layout.svelte"); +const { solverState } = await import("./solver.svelte"); +const { solverStorage } = await import("../storage/storage"); +const { createPlacementAtLevel } = await import("../data/placements"); +const { SolverWorkerClient } = await import("../worker/workerClient"); + +const ISLAND = "island1"; +const template = ISLAND_TEMPLATES.find((t) => t.id === ISLAND)!; +const { grid: mapGrid } = await decodeBlueprint(template.code); +const grass = mapGrid.flat().filter((t) => t.type === "grass"); + +/** A one-building layout on the `i`th grass tile of the island's map. */ +function layout(i: number, buildingId = "cooler1"): PlacedBuilding[] { + return [createPlacementAtLevel(buildingId, grass[i].x, grass[i].y, 0)]; +} + +function result(placements: PlacedBuilding[]): OptimizationResult { + return { + totalPower: 0, + placements, + activeTilesCount: placements.length, + unusedTilesCount: 0, + summary: { + totalHeatProduced: 0, + totalHeatConsumed: 0, + totalWasteGenerated: 0, + totalCoolingCapacity: 0, + }, + theoreticalMaxPower: 0, + }; +} + +/** Stores a solve for the island, as a finished run would have. */ +function stage( + variants: PlacedBuilding[][], + selected = 0, + anomalyId: AnomalyId = "none", + signature = "the roster before the import", +) { + solverStorage.saveSolve({ + templateId: ISLAND, + signature, + result: result(variants[selected]), + variants: variants.map((v) => + v.map((p): StoredPlacement => [p.buildingId, p.x, p.y, p.baseValue]), + ), + selectedVariant: selected, + anomalyId, + planKey: "", + durationMs: 30_000, + finishedAt: 1, + }); +} + +/** The island's map with tile `i` turned to rock, saved as its layout. */ +async function rockOn(i: number) { + const grid: Tile[][] = mapGrid.map((row) => + row.map((t) => + t.x === grass[i].x && t.y === grass[i].y ? { ...t, type: "rock" } : t, + ), + ); + layoutState.savedGrids[ISLAND] = { code: await encodeBlueprint(grid, []) }; +} + +const shapesOf = (id: string) => + solverStorage.loadSolve(id)?.variants?.map((v) => [v[0][1], v[0][2]]); + +describe("carrying stored solves through a game import", () => { + beforeEach(async () => { + store.clear(); + layoutState.savedGrids = {}; + layoutState.customIslands = []; + layoutState.activeTemplateId = ISLAND; + await layoutState.hydrate({}); + // The roster the import brings: a different one from the solve's. + configState.buildingUpgrades = { cooler1: 5, generator: 0 }; + configState.prestigeLevels = {}; + configState.setAnomaly("none"); + }); + + it("keeps a solve the import left buildable, re-filed for the new roster", async () => { + stage([layout(0)]); + await solverState.carryOverSolves([]); + + const saved = solverStorage.loadSolve(ISLAND)!; + expect(saved.signature).toBe(solverState.solveSignature(mapGrid)); + // Re-tiered to what the import unlocks. + expect(saved.result.placements[0].baseValue).toBe( + createPlacementAtLevel("cooler1", 0, 0, 5).baseValue, + ); + // And it is what the island now restores. + solverState.restore(); + expect(solverState.variants).toHaveLength(1); + }); + + it("drops a solve for an island still locked in the game", async () => { + stage([layout(0)]); + await solverState.carryOverSolves([ISLAND]); + expect(solverStorage.loadSolve(ISLAND)).toBeNull(); + }); + + it("drops a solve searched under another anomaly", async () => { + stage([layout(0)], 0, "cryo_nexus"); + await solverState.carryOverSolves([]); + expect(solverStorage.loadSolve(ISLAND)).toBeNull(); + }); + + it("drops only the layouts an imported obstacle now covers", async () => { + stage([layout(0), layout(1), layout(2)], 1); + await rockOn(0); + await solverState.carryOverSolves([]); + + expect(shapesOf(ISLAND)).toEqual([ + [grass[1].x, grass[1].y], + [grass[2].x, grass[2].y], + ]); + // The pick survives and keeps pointing at the same layout. + expect(solverStorage.loadSolve(ISLAND)?.selectedVariant).toBe(0); + }); + + it("drops the solve once an obstacle covers every layout", async () => { + stage([layout(0)]); + await rockOn(0); + await solverState.carryOverSolves([]); + expect(solverStorage.loadSolve(ISLAND)).toBeNull(); + }); + + it("drops a layout that uses a building the import does not unlock", async () => { + stage([layout(0, "solar_panel"), layout(1)]); + await solverState.carryOverSolves([]); + expect(shapesOf(ISLAND)).toEqual([[grass[1].x, grass[1].y]]); + + stage([layout(0, "solar_panel")]); + await solverState.carryOverSolves([]); + expect(solverStorage.loadSolve(ISLAND)).toBeNull(); + }); +}); + +/* + * The same keep-what-still-stands rule for a change made in Setup. The island + * on screen is re-rated as the change happens (`rescoreResult`); one off + * screen is re-rated by `restore()` when it is next opened, rather than + * discarded because its signature names the old roster. + */ +/* + * A stop only asks a run to end: the workers finish their pass, and the result + * is scored and filed after. A game import that changed the rules in that gap + * had the late result scored under the imported ones and filed over the + * record `carryOverSolves` had just re-filed. + */ +describe("a game import over a run still finishing", () => { + const proto = SolverWorkerClient.prototype as unknown as Record< + string, + unknown + >; + const original = proto.solveProgressive; + + beforeEach(async () => { + store.clear(); + layoutState.savedGrids = {}; + layoutState.customIslands = []; + layoutState.activeTemplateId = ISLAND; + await layoutState.hydrate({}); + configState.buildingUpgrades = { cooler1: 0, solar_panel: 0 }; + configState.prestigeLevels = {}; + configState.setAnomaly("none"); + }); + + it("waits until the run has filed its result under its own rules", async () => { + let finish!: (variants: OptimizationResult[]) => void; + let stopped = false; + proto.solveProgressive = () => ({ + promise: new Promise((r) => (finish = r)), + stop: () => void (stopped = true), + }); + try { + const signature = solverState.solveSignature(); + const run = solverState.runOptimizer(); + let settled = false; + const settle = solverState + .settleOptimizer() + .then(() => void (settled = true)); + + await new Promise((r) => setTimeout(r, 0)); + expect(stopped).toBe(true); + expect(settled).toBe(false); + + finish([result(layout(0))]); + await settle; + expect(solverStorage.loadSolve(ISLAND)?.signature).toBe(signature); + await run; + } finally { + proto.solveProgressive = original; + } + }); + + it("returns at once when nothing is running", async () => { + await solverState.settleOptimizer(); + expect(solverState.isOptimizing).toBe(false); + }); +}); + +describe("opening an island after Setup changed", () => { + /** A solve filed for this terrain under the roster and rules set now. */ + function stageHere(variants: PlacedBuilding[][]) { + stage(variants, 0, "none", solverState.solveSignature(mapGrid)); + } + + beforeEach(async () => { + store.clear(); + layoutState.savedGrids = {}; + layoutState.customIslands = []; + layoutState.activeTemplateId = ISLAND; + await layoutState.hydrate({}); + configState.buildingUpgrades = { cooler1: 0, solar_panel: 0 }; + configState.prestigeLevels = {}; + configState.setAnomaly("none"); + }); + + it("re-rates a solve whose roster changed while it was off screen", () => { + stageHere([layout(0)]); + configState.buildingUpgrades = { cooler1: 5, solar_panel: 0 }; + configState.prestigeLevels = { absolute_zero: 2 }; + + solverState.restore(); + + expect(solverState.variants).toHaveLength(1); + expect(solverState.variants[0].placements[0].baseValue).toBe( + createPlacementAtLevel("cooler1", 0, 0, 5).baseValue, + ); + expect(solverStorage.loadSolve(ISLAND)?.signature).toBe( + solverState.solveSignature(), + ); + }); + + it("keeps a solve across an anomaly change, still saying where it was found", () => { + stageHere([layout(0)]); + configState.setAnomaly("cryo_nexus"); + + solverState.restore(); + + expect(solverState.variants).toHaveLength(1); + expect(solverState.resultAnomalyId).toBe("none"); + }); + + it("drops the layouts that use a building Setup has locked", () => { + stageHere([layout(0, "solar_panel"), layout(1)]); + configState.buildingUpgrades = { cooler1: 0 }; + + solverState.restore(); + expect(solverState.variants).toHaveLength(1); + + // And the whole solve, once none of its layouts can still be built. + configState.buildingUpgrades = { cooler1: 0, solar_panel: 0 }; + stageHere([layout(0, "solar_panel")]); + configState.buildingUpgrades = { cooler1: 0 }; + solverState.restore(); + expect(solverStorage.loadSolve(ISLAND)).toBeNull(); + expect(solverState.variants).toHaveLength(0); + }); + + /* + * The first records were `terrain|roster`, from before there were anomalies, + * so the field an anomaly is read from in a record that names none is a + * piece of the terrain key. Read as an id, it left the solve forever "found + * under another timeline". + */ + it("reads a solve from before anomalies as found under the base rules", () => { + stage([layout(0)], 0, "none", `${blueprintKey(mapGrid)}|cooler1:0`); + const saved = solverStorage.loadSolve(ISLAND)!; + delete saved.anomalyId; + solverStorage.saveSolve(saved); + configState.buildingUpgrades = { cooler1: 5, solar_panel: 0 }; + + solverState.restore(); + + expect(solverState.variants).toHaveLength(1); + expect(solverState.resultAnomalyId).toBe("none"); + expect(solverStorage.loadSolve(ISLAND)?.anomalyId).toBe("none"); + }); + + it("still discards a solve for terrain that has changed", () => { + stage([layout(1)], 0, "none", "another board|cooler1:0|none|"); + solverState.restore(); + expect(solverStorage.loadSolve(ISLAND)).toBeNull(); + expect(solverState.variants).toHaveLength(0); + }); +}); diff --git a/src/lib/state/solvePersistence.test.ts b/src/lib/state/solvePersistence.test.ts index 83ac1e1..f629b99 100755 --- a/src/lib/state/solvePersistence.test.ts +++ b/src/lib/state/solvePersistence.test.ts @@ -173,6 +173,53 @@ describe("persisted solve results", () => { expect(activeSolve()).toBeNull(); }); + describe("the run dialog", () => { + /** + * What the board and storage held at the moment the run was started, with + * the run itself stood in for: what is under test is what the dialog does + * before it, not the search. + */ + async function startFromDialog(keepBest: boolean) { + const { uiState } = await import("./ui.svelte"); + let seen: { onScreen: number | null; stored: boolean } | null = null; + const real = solverState.runOptimizer; + solverState.runOptimizer = async () => { + seen = { + onScreen: solverState.optimizationResult?.totalPower ?? null, + stored: activeSolve() !== null, + }; + }; + try { + uiState.startSolve(keepBest); + } finally { + solverState.runOptimizer = real; + } + return seen; + } + + it("deletes a discarded solve before the run starts", async () => { + stageSolve(fakeResult(500)); + solverState.restore(); + + // Gone from the board and from storage straight away, so a run stopped + // before it reports anything cannot hand the discarded layout back. + expect(await startFromDialog(false)).toEqual({ + onScreen: null, + stored: false, + }); + }); + + it("leaves a kept solve where it is", async () => { + stageSolve(fakeResult(500)); + solverState.restore(); + + expect(await startFromDialog(true)).toEqual({ + onScreen: 500, + stored: true, + }); + }); + }); + it("keeps a solve when the user places a building by hand", () => { stageSolve(fakeResult(500)); diff --git a/src/lib/state/solveVariants.test.ts b/src/lib/state/solveVariants.test.ts index e01634a..ad5f0ce 100755 --- a/src/lib/state/solveVariants.test.ts +++ b/src/lib/state/solveVariants.test.ts @@ -10,7 +10,7 @@ * An app rule, like `solvePersistence.test.ts` — the engine solves * for one layout and has nothing to say about any of it. */ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OptimizationResult, PlacedBuilding } from "../types"; // The storage helpers are SSR-guarded on `typeof window`, so a test running in @@ -26,9 +26,14 @@ const store = new Map(); }; const { layoutState } = await import("./layout.svelte"); -const { solverState, chooseSolveVariants } = await import("./solver.svelte"); +const { solverState, chooseSolveVariants, composeIslandBests } = + await import("./solver.svelte"); const { solverStorage } = await import("../storage/storage"); +const { configState } = await import("./config.svelte"); const { SolverWorkerClient } = await import("../worker/workerClient"); +const { simulatePlacedBuildings } = await import("../simulation/simulator"); +const { createPlacementAtLevel } = await import("../data/placements"); +const { BUILDINGS } = await import("@reactor2/solver"); function placed(buildingId: string, x: number, y: number): PlacedBuilding { return { @@ -190,6 +195,91 @@ describe("what a finished run does to the shortlist", () => { }); }); +describe("taking each island's best across two runs", () => { + /** + * A board of two islands, x < 100 and x >= 100, worth `a` and `b`. `shape` + * moves both islands' buildings, so boards of different shapes are distinct + * layouts on each island separately. + */ + function twoIslands(a: number, b: number, shape: number): OptimizationResult { + const board = result(a + b, 0); + const row = (x0: number, power: number) => + [0, 1, 2, 3, 4].map((k) => ({ + ...placed("generator", x0 + shape * 5 + k, 0), + powerGenerated: k === 0 ? power : 0, + })); + board.placements = [...row(0, a), ...row(100, b)]; + return board; + } + const islandOf = (p: PlacedBuilding) => (p.x < 100 ? 0 : 1); + const sum = (rows: PlacedBuilding[]) => + rows.reduce((s, p) => s + p.powerGenerated, 0); + const shapeOf = (rows: PlacedBuilding[], island: number) => + (rows.filter((p) => islandOf(p) === island)[0].x - island * 100) / 5; + + /* + * The case it exists for: the fresh run wins on the total by finding a + * better large island, and would have taken a worse small one with it. + */ + it("keeps the held island the fresh run did worse on", () => { + const held = [twoIslands(100, 200, 0)]; + const fresh = [twoIslands(150, 180, 1)]; + const composed = composeIslandBests(held, fresh, islandOf, sum)!; + + expect(composed.map(sum)).toEqual([350]); + expect(shapeOf(composed[0], 0)).toBe(1); + expect(shapeOf(composed[0], 1)).toBe(0); + }); + + it("has nothing to add when one board already holds every best", () => { + const held = [twoIslands(100, 200, 0)]; + expect( + composeIslandBests(held, [twoIslands(150, 200, 1)], islandOf, sum), + ).toBeNull(); + expect( + composeIslandBests(held, [twoIslands(90, 190, 1)], islandOf, sum), + ).toBeNull(); + }); + + it("is never asked to merge when nothing is held", () => { + expect( + composeIslandBests([], [twoIslands(1, 2, 0)], islandOf, sum), + ).toBeNull(); + }); + + /* + * Every layout is upgraded, so a tied alternate on the island it was + * already best on survives as an alternate of the merged board. + */ + it("upgrades the shortlist too, and drops the copies that makes", () => { + const held = [twoIslands(100, 200, 0), twoIslands(100, 200, 2)]; + const fresh = [twoIslands(150, 180, 1), twoIslands(150, 180, 3)]; + const composed = composeIslandBests(held, fresh, islandOf, sum)!; + + // The fresh run's leader, upgraded, is the held leader upgraded: one board. + expect(composed.map(sum)).toEqual([350, 350, 350]); + expect(composed.map((c) => [shapeOf(c, 0), shapeOf(c, 1)])).toEqual([ + [1, 0], + [1, 2], + [3, 0], + ]); + }); + + it("feeds a merge that beats both runs through as the new shortlist", () => { + const held = [twoIslands(100, 200, 0)]; + const merged = composeIslandBests( + held, + [twoIslands(150, 180, 1)], + islandOf, + sum, + )!.map((placements) => ({ ...result(sum(placements), 0), placements })); + + const chosen = chooseSolveVariants(held, 0, merged); + expect(chosen.defended).toBe(false); + expect(chosen.variants[0].totalPower).toBe(350); + }); +}); + describe("a shortlist that survives a reload", () => { /** Where the buildings can actually go, so the re-score has something to do. */ const grassAt = (n: number) => { @@ -390,4 +480,142 @@ describe("what a re-run leaves on screen", () => { expect(solverState.optimizationResult?.totalPower).toBe(500); expect(solverState.variants.map((v) => v.totalPower)).toEqual([500]); }); + + /* + * The merge as the app reaches it: two islands, each run winning one of + * them, and real scoring on the way out — the composite is re-scored on the + * board, so this is what proves it is a legal board worth the sum. + */ + it("keeps the better of each island across the two runs", async () => { + // Two three-tile islands, rows 0 and 4, with water between. + layoutState.grid = ["GGG", "~~~", "~~~", "~~~", "GGG"].map((row, y) => + [...row].map((c, x) => ({ + x, + y, + type: c === "G" ? ("grass" as const) : ("water" as const), + })), + ); + /** A reactor, generator and cooler along row `y`, the reactor at `tier`. */ + const hub = (y: number, tier: number) => [ + createPlacementAtLevel("solar_panel", 0, y, tier), + createPlacementAtLevel("generator", 1, y, 0), + createPlacementAtLevel("cooler1", 2, y, 5), + ]; + const board = (rows: PlacedBuilding[]): OptimizationResult => { + const scored = simulatePlacedBuildings(layoutState.grid, BUILDINGS, rows); + const power = scored.reduce((sum, p) => sum + p.powerGenerated, 0); + return { ...result(power, 0), placements: scored }; + }; + const strong = board(hub(0, 5)).totalPower; + const weak = board(hub(0, 0)).totalPower; + expect(weak).toBeGreaterThan(0); + expect(strong).toBeGreaterThan(weak); + + fake = [board([...hub(0, 5), ...hub(4, 0)])]; + await solverState.runOptimizer(); + // The two tie on the total, so the whole-board rules could only pool + // them; neither board on its own has both strong islands. + fake = [board([...hub(0, 0), ...hub(4, 5)])]; + await solverState.runOptimizer({ keepBest: true }); + + expect(solverState.optimizationResult?.totalPower).toBeCloseTo(2 * strong); + }); + + describe("merging islands on a board that has moved on", () => { + /** Two three-tile islands, rows 0 and 4, with water between. */ + const twoIslands = () => + ["GGG", "~~~", "~~~", "~~~", "GGG"].map((row, y) => + [...row].map((c, x) => ({ + x, + y, + type: c === "G" ? ("grass" as const) : ("water" as const), + })), + ); + /** A reactor, generator and cooler along row `y`. */ + const hub = (y: number) => [ + createPlacementAtLevel("solar_panel", 0, y, 5), + createPlacementAtLevel("generator", 1, y, 0), + createPlacementAtLevel("cooler1", 2, y, 5), + ]; + /** The same row with no reactor, so nothing to convert: worth nothing. */ + const idle = (y: number) => [ + createPlacementAtLevel("generator", 1, y, 0), + createPlacementAtLevel("cooler1", 2, y, 5), + ]; + const board = (rows: PlacedBuilding[]): OptimizationResult => { + const scored = simulatePlacedBuildings(layoutState.grid, BUILDINGS, rows); + const power = scored.reduce((sum, p) => sum + p.powerGenerated, 0); + return { ...result(power, 0), placements: scored }; + }; + const reactorRows = (v: OptimizationResult | null | undefined) => + (v?.placements ?? []) + .filter((p) => p.buildingId === "solar_panel") + .map((p) => p.y) + .sort(); + + let roster: Record = {}; + beforeEach(() => { + roster = { ...configState.buildingUpgrades }; + configState.lockAll(); + configState.setUpgradeLevel("solar_panel", 5); + configState.setUpgradeLevel("generator", 0); + configState.setUpgradeLevel("cooler1", 5); + layoutState.grid = twoIslands(); + }); + afterEach(() => { + while (configState.upgradePlan.length > 0) configState.removePlanStep(0); + configState.lockAll(); + for (const [id, level] of Object.entries(roster)) + configState.setUpgradeLevel(id, level); + }); + + /* + * Under a plan an island is judged by `evaluatePlan` on its placements + * alone, so the merge leans on that figure adding up across islands. A + * part-board it could not rate would read as zero and the merge would + * never fire. + */ + it("takes each island's best under an upgrade plan too", async () => { + configState.addPlanStep("generator"); + expect(configState.hasPlan).toBe(true); + + fake = [board([...hub(0), ...idle(4)])]; + await solverState.runOptimizer(); + fake = [board([...idle(0), ...hub(4)])]; + await solverState.runOptimizer({ keepBest: true }); + + expect(reactorRows(solverState.optimizationResult)).toEqual([0, 4]); + }); + + /* + * A held layout is the board at its own run. Paint under one of its + * buildings and that island is no longer something a new board can be + * built from: the merge would carry the building on the rock across. + * + * The building painted over is a spare cooler, so losing it costs the held + * island nothing — which is exactly when a merged board carrying it would + * win and be kept. + */ + it("does not take an island from a held layout the terrain has left", async () => { + const wider = () => { + const grid = twoIslands(); + grid[0].push({ x: 3, y: 0, type: "grass" }); + for (const row of grid.slice(1)) + row.push({ x: 3, y: row[0].y, type: "water" }); + return grid; + }; + layoutState.grid = wider(); + const spare = createPlacementAtLevel("cooler1", 3, 0, 5); + fake = [board([...hub(0), spare, ...idle(4)])]; + await solverState.runOptimizer(); + + layoutState.grid = wider(); + layoutState.grid[0][3] = { x: 3, y: 0, type: "rock" }; + fake = [board([...idle(0), ...hub(4)])]; + await solverState.runOptimizer({ keepBest: true }); + + for (const v of solverState.variants) + expect(reactorRows(v)).not.toEqual([0, 4]); + }); + }); }); diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index 0478f0c..5acb06b 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -1,5 +1,10 @@ -import type { OptimizationResult, PlacedBuilding } from "../types"; -import { DEFAULT_ANOMALY_ID, type AnomalyId } from "@reactor2/solver"; +import type { OptimizationResult, PlacedBuilding, Tile } from "../types"; +import { + DEFAULT_ANOMALY_ID, + getAnomaly, + type AnomalyDefinition, + type AnomalyId, +} from "@reactor2/solver"; import { SolverWorkerClient, type SolveTaskHandle, @@ -33,6 +38,7 @@ import { trackEvent } from "../utils/analytics"; import { solverStorage, uiStorage, + type SavedSolveData, type StoredPlacement, } from "../storage/storage"; import { configState } from "./config.svelte"; @@ -126,6 +132,78 @@ export function chooseSolveVariants( return { variants: fresh.slice(0, limit), selected: 0, defended: false }; } +/** + * Boards built from the best answer each island has had, across both runs. + * + * `chooseSolveVariants` weighs whole boards, and on a board of several islands + * that loses work: a re-run that finds a better large island and a worse small + * one wins on the total and takes the worse small island with it, although the + * held board's version is still sitting in the shortlist. Islands never + * interact, so any mix of one board's islands with another's is a legal board + * whose power is the sum of the parts — and taking the stronger side island by + * island is at least as good as either board. + * + * Every layout in either shortlist is upgraded this way, not only the leaders, + * so the arrangements a shortlist offers survive wherever they were already + * the best answer for their island. Each comes out at the same power, which is + * what makes the result a shortlist again. + * + * Null when one board already holds the best of every island — the merge + * would only rebuild it, and the plain whole-board rules say everything there + * is to say. `islandPower` is the figure the run maximised for those + * placements alone, so under an upgrade plan it is the power at the plan's end. + */ +export function composeIslandBests( + held: readonly OptimizationResult[], + fresh: readonly OptimizationResult[], + islandOf: (p: PlacedBuilding) => number, + islandPower: (placements: PlacedBuilding[]) => number, + limit: number = MAX_SOLVE_VARIANTS, +): PlacedBuilding[][] | null { + if (held.length === 0 || fresh.length === 0) return null; + + const boards = [...held, ...fresh].map((variant) => { + const byIsland = new Map(); + for (const p of variant.placements) { + const key = islandOf(p); + const rows = byIsland.get(key); + if (rows) rows.push(p); + else byIsland.set(key, [p]); + } + return byIsland; + }); + const islands = [...new Set(boards.flatMap((b) => [...b.keys()]))]; + const power = boards.map( + (b) => new Map(islands.map((k) => [k, islandPower(b.get(k) ?? [])])), + ); + + // Which board holds each island's best answer. + const owner = new Map(); + for (const k of islands) { + let best = 0; + for (let i = 1; i < boards.length; i++) + if (power[i].get(k)! > power[best].get(k)!) best = i; + owner.set(k, best); + } + + /** Strictly below, and by more than the precision the figures carry. */ + const falls = (i: number, k: number) => { + const mine = power[i].get(k)!; + const top = power[owner.get(k)!].get(k)!; + return mine < top && !powerTies(mine, top); + }; + if (boards.some((_, i) => islands.every((k) => !falls(i, k)))) return null; + + const composed: PlacedBuilding[][] = []; + for (let i = 0; i < boards.length && composed.length < limit; i++) { + const placements = islands.flatMap( + (k) => boards[falls(i, k) ? owner.get(k)! : i].get(k) ?? [], + ); + if (isDistinctLayout(placements, composed)) composed.push(placements); + } + return composed; +} + /** * A layout stripped to what storage keeps: which building, where, and the tier * it was placed at. Everything else is re-derived by scoring it — see @@ -136,6 +214,22 @@ function toStoredPlacements(placements: PlacedBuilding[]): StoredPlacement[] { } /** The other direction: bare shape back to unscored placements. */ +/** + * The anomaly a stored solve's shapes were searched under. A record from before + * `anomalyId` existed carries it in its signature, second field from the end. + */ +function searchedAnomaly(saved: SavedSolveData): AnomalyId { + if (saved.anomalyId) return saved.anomalyId; + // Counted from the end: the terrain key in front is raw bytes and may hold + // a "|" of its own, while the roster, anomaly and research never do. A + // record older still is `terrain|roster`, from before there were anomalies, + // so that field is a piece of the terrain key — and `getAnomaly` reads + // anything it does not know as the base rules, which is what that solve was + // searched under. + const fields = saved.signature.split("|"); + return getAnomaly(fields[fields.length - 2]).id; +} + function restorePlacements(stored: StoredPlacement[]): PlacedBuilding[] { return stored.map(([buildingId, x, y, baseValue]) => unscoredPlacement(buildingId, x, y, baseValue), @@ -253,7 +347,7 @@ class SolverState { * * Persisted, because it is a standing answer to "how much wall-clock am I * willing to give this", not a per-island decision. It deliberately does not - * take part in `solveSignature()`: a layout found by ten short searches is + * take part in `solveSignature()`: a layout found by five short searches is * the same kind of answer as one found by a single long one, so changing the * mode does not make a stored solve stale. */ @@ -301,6 +395,9 @@ class SolverState { #watchdog: ReturnType | null = null; + /** Settles once the run in flight has filed its result; null between runs. */ + #runEnded: Promise | null = null; + /** * The chosen shape of a run, as it runs under the active anomaly. Falls back * rather than throwing on a stale id. @@ -331,7 +428,7 @@ class SolverState { * * Not `attempts × budget`: the attempts are pool tasks and overlap, so the * cost of a deep run depends on how many cores the browser reports. Printing - * it is the whole reason the choice can be made honestly — "10 × 10s" says + * it is the whole reason the choice can be made honestly — "5 × 20s" says * nothing about whether that means twenty seconds or a hundred. * * Takes the mode rather than reading the current one so the explainer can @@ -363,10 +460,8 @@ class SolverState { // carries the board's real terrain, so a neighbouring island's tiles sit // inside it and counting them would inflate every budget estimate. const tileCounts = islands.map((island) => island.tileCount); - return estimateMakespanMs( - taskDurationsMs(mode, tileCounts), - defaultPoolSize(), - ); + const pool = defaultPoolSize(); + return estimateMakespanMs(taskDurationsMs(mode, tileCounts, pool), pool); } /** The same figure for the mode a run would use right now. */ @@ -384,7 +479,11 @@ class SolverState { /** Theoretical upper bound maximum power estimate for current grid and unlocked buildings. */ get estimatedMaxPower(): number { - const grid = layoutState.grid; + return this.#maxPowerFor(layoutState.grid); + } + + /** `estimatedMaxPower` for any board, not only the one on screen. */ + #maxPowerFor(grid: Tile[][]): number { if (!grid || !grid.length || !grid[0] || !grid[0].length) return 0; const upgrades = configState.buildingUpgrades; @@ -457,7 +556,7 @@ class SolverState { * Public because it is a plain query — and because it is what a test needs * to stage a stored solve without running a five-minute solve first. */ - solveSignature(): string { + solveSignature(grid: Tile[][] = layoutState.grid): string { const roster = Object.entries(configState.buildingUpgrades) .sort(([a], [b]) => a.localeCompare(b)) .map(([id, level]) => `${id}:${level}`) @@ -470,7 +569,7 @@ class SolverState { .sort(([a], [b]) => a.localeCompare(b)) .map(([id, level]) => `${id}:${level}`) .join(","); - return `${blueprintKey(layoutState.grid)}|${roster}|${configState.anomalyId}|${research}`; + return `${blueprintKey(grid)}|${roster}|${configState.anomalyId}|${research}`; } /** @@ -488,12 +587,25 @@ class SolverState { */ restore() { const templateId = layoutState.activeTemplateId; - const saved = solverStorage.loadSolve(templateId); - - // A repainted board or a different roster is not a stale view of the - // truth — it is a different question's answer. - if (!saved || saved.signature !== this.solveSignature()) { - if (saved) solverStorage.clearSolve(templateId); + let saved = solverStorage.loadSolve(templateId); + + // A repainted board is a different question, and its answer goes. A + // roster, research or anomaly changed in Setup while this island was off + // screen is not: the shape still stands on this terrain, so it is re-rated + // the way `rescoreResult` re-rated the island that *was* on screen — + // otherwise every other island's solve was lost on its next open. + if (saved && saved.signature !== this.solveSignature()) { + const sameTerrain = saved.signature.startsWith( + `${blueprintKey(layoutState.grid)}|`, + ); + if (sameTerrain && this.#refile(templateId, saved, layoutState.grid)) + saved = solverStorage.loadSolve(templateId); + else { + solverStorage.clearSolve(templateId); + saved = null; + } + } + if (!saved) { this.#reset(); return; } @@ -536,11 +648,65 @@ class SolverState { * scored. `bound` is passed in rather than read per call: it is the same * figure for every variant and computing it re-splits the whole grid. */ + /** + * `composeIslandBests` over the held and fresh shortlists, scored — or null + * where the merge has nothing to add. + * + * The islands are split the way the run split them: the roster it searched + * decides whether a lone patch can be cooled, and the anomaly can decide + * whether there are islands at all. Under a shared cooling pool the board is + * one island, so a merge never finds anything to do. Placements on no island + * share one key, which keeps them with whichever board they came from. + */ + #composeIslands( + held: OptimizationResult[], + fresh: OptimizationResult[], + grid: Tile[][], + unlocks: Record, + anomaly: AnomalyDefinition, + islandPower: (placements: PlacedBuilding[]) => number, + ): OptimizationResult[] | null { + // A held layout is the board as it stood at its own run, and terrain + // painted since may have put something under one of its buildings. The + // whole board defending itself is `chooseSolveVariants`' concern; taking + // one island of it into a new board is this one's, so only layouts still + // standing on grass are offered — the test `#refile` applies. + const standing = held.filter((v) => + v.placements.every((p) => grid[p.y]?.[p.x]?.type === "grass"), + ); + if (standing.length === 0) return null; + + const width = grid[0]?.length ?? 0; + const islandAt = new Map(); + const islands = splitGridIntoIslands( + grid, + canCoolDirectProducer( + getEffectiveBuildings(BUILDINGS, unlocks, configState.prestige), + ), + anomaly, + ); + islands.forEach((island, i) => + island.originalTileIndices.forEach((tile, k) => { + if (island.buildable[k] === 1) islandAt.set(tile, i); + }), + ); + + const composed = composeIslandBests( + standing, + fresh, + (p) => islandAt.get(p.y * width + p.x) ?? -1, + islandPower, + ); + if (!composed) return null; + const bound = this.estimatedMaxPower; + return composed.map((placements) => this.#scoreLayout(placements, bound)); + } + #scoreLayout( placements: PlacedBuilding[], bound: number, + grid: Tile[][] = layoutState.grid, ): OptimizationResult { - const grid = layoutState.grid; const scored = simulatePlacedBuildings( grid, BUILDINGS, @@ -609,6 +775,95 @@ class SolverState { solverStorage.clearSolve(templateId); } + /** + * What importing the game does to every island's stored solve, decided per + * island against the board the import left it with. + * + * A game import changes the roster, the research and usually the terrain, + * and each of those is in `solveSignature()` — so left alone, `restore()` + * would discard every island's solve the next time it was opened, though + * most of them are still perfectly buildable. A solve can take minutes, so + * only what is actually invalid goes: + * + * - an island **still locked** in the game, whose board was reset to its map; + * - a solve **searched under another anomaly** — its shapes answer other + * rules, and re-rating them would pass them off as this timeline's; + * - a layout with a building on a tile the import made **unbuildable**, or + * one the imported roster **does not unlock** — dropped from the + * shortlist, and the solve with it once none survive. + * + * Everything else is kept: re-tiered to the imported unlocks, re-scored on + * the island's new board, and filed under the signature that board and + * roster now give, as `rescoreResult` does for the island on screen. Like it, + * this searches nothing, so a cleared obstacle shows up as room a re-run can + * use rather than as a changed layout. + */ + async carryOverSolves(locked: readonly string[]) { + for (const templateId of solverStorage.solveIds()) { + const saved = solverStorage.loadSolve(templateId); + if (!saved) continue; + const grid = locked.includes(templateId) + ? null + : await layoutState.boardOf(templateId); + if (!grid || searchedAnomaly(saved) !== configState.activeAnomaly.id) { + solverStorage.clearSolve(templateId); + continue; + } + + if (!this.#refile(templateId, saved, grid)) + solverStorage.clearSolve(templateId); + } + } + + /** + * Re-files a stored solve for `grid` under the roster, research and anomaly + * now in force, without searching again — or returns false when nothing in + * it can still be built there. + * + * A layout is dropped if a building stands on a tile that is no longer + * grass, or is one the roster no longer unlocks; the rest are re-tiered to + * the current unlocks, re-scored on `grid`, and written back under the + * signature `grid` now gives. The record keeps the anomaly its shapes were + * *searched* under, so a solve carried into another timeline still says so. + */ + #refile(templateId: string, saved: SavedSolveData, grid: Tile[][]): boolean { + const upgrades = $state.snapshot(configState.buildingUpgrades); + const shapes = saved.variants?.length + ? saved.variants.map(restorePlacements) + : [saved.result.placements]; + // Standing on grass, and something the roster still unlocks. + const buildable = (p: PlacedBuilding) => + grid[p.y]?.[p.x]?.type === "grass" && p.buildingId in upgrades; + const kept = shapes + .map((placements, index) => ({ placements, index })) + .filter(({ placements }) => placements.every(buildable)); + if (kept.length === 0) return false; + + const bound = this.#maxPowerFor(grid); + const variants = kept.map(({ placements }) => + this.#scoreLayout( + rebaseToUnlocks(placements, upgrades) ?? placements, + bound, + grid, + ), + ); + const selected = Math.max( + 0, + kept.findIndex(({ index }) => index === (saved.selectedVariant ?? 0)), + ); + this.#persistResult( + variants, + selected, + saved.durationMs, + saved.finishedAt, + templateId, + this.solveSignature(grid), + searchedAnomaly(saved), + saved.planKey ?? "", + ); + return true; + } + /** * Re-scores the layout on screen at the player's current unlock levels. * @@ -802,6 +1057,12 @@ class SolverState { runAnomaly, )?.power ?? 0) : result.totalPower; + /** `rank` for one island's placements alone, read off the scored rows. */ + const islandPower = (placements: PlacedBuilding[]): number => + runPlan + ? (evaluatePlan(runGrid, placements, runPlan, runPrestige, runAnomaly) + ?.power ?? 0) + : placements.reduce((sum, p) => sum + p.powerGenerated, 0); /* * The bar a streaming snapshot has to clear to be worth putting on screen, * and it is the same bar `chooseSolveVariants` will judge the finished run @@ -865,6 +1126,8 @@ class SolverState { // the run's island is still on screen. let runError: string | null = null; let donePower = 0; + let endRun!: () => void; + this.#runEnded = new Promise((resolve) => (endRun = resolve)); try { // $state.snapshot is required before anything crosses postMessage: @@ -958,11 +1221,26 @@ class SolverState { !this.optimizationError ) { // `previousVariants` is empty unless the user asked to keep the better - // layout, so without that this always resolves to the fresh run. + // layout, so without that this always resolves to the fresh run. When + // each run won some islands, the boards made of every island's winner + // beat both, and stand in for the fresh run (`composeIslandBests`). + // Scored against the board on screen, so only while it is this run's. + const composed = onScreen() + ? this.#composeIslands( + previousVariants, + finalVariants, + runGrid, + runPlan + ? runPlan.target + : $state.snapshot(configState.buildingUpgrades), + runAnomaly, + islandPower, + ) + : null; const { variants, selected, defended } = chooseSolveVariants( previousVariants, previousIndex, - finalVariants, + composed ?? finalVariants, MAX_SOLVE_VARIANTS, rank, ); @@ -1031,6 +1309,11 @@ class SolverState { this.resultPlanKey = previousPlanKey; } + // Filed: a caller waiting on `settleOptimizer` may change the rules now. + // Before the analytics below, which must not be able to strand it. + this.#runEnded = null; + endRun(); + // Every `solve_run` gets one of these, so a status other than `ok` is // countable rather than inferred from a run that simply never reported. const bound = this.estimatedMaxPower; @@ -1057,12 +1340,35 @@ class SolverState { } } + /** + * `stopOptimizer`, then waits until the run has filed its result. + * + * A stop only asks: the workers finish the pass they are on, and the result + * is scored and filed after. That is harmless when the board merely leaves + * the screen, but not before the rules change under it — a game import that + * went on without waiting would have the late result scored against the + * imported board and roster, and filed over the record `carryOverSolves` + * had just re-filed. Waiting lets it land under the rules it was searched + * under, where the carry-over picks it up like any other stored solve. + */ + async settleOptimizer(): Promise { + this.stopOptimizer(); + await this.#runEnded; + } + /** * The backstop is set against the run's **serial** cost — every attempt of - * every island one after another — not against the estimate the button - * prints. The estimate assumes a pool that is actually running in parallel; - * a machine that reports eight cores and gives two would trip a watchdog set - * to it, and cutting a run short is worse than letting a slow one finish. + * every island one after another, as `islandBudgetsS` divides them — not + * against the estimate the button prints. The estimate assumes a pool that is + * actually running in parallel; a machine that reports eight cores and gives + * two would trip a watchdog set to it, and cutting a run short is worse than + * letting a slow one finish. + * + * A run `fitBudgetsToPool` stretched spends more CPU than that, but only one + * whose every task has a worker of its own is stretched, and the search's + * deadlines are wall-clock: each task stops at its slice however busy the + * cores are, so the run still ends inside one attempt's budget. It would + * outlast this only if the browser started the workers one after another. */ #startWatchdog(mode: SolveMode) { this.#stopWatchdog(); diff --git a/src/lib/state/ui.svelte.ts b/src/lib/state/ui.svelte.ts index 559150b..8c4ab59 100755 --- a/src/lib/state/ui.svelte.ts +++ b/src/lib/state/ui.svelte.ts @@ -713,9 +713,18 @@ class UIState { this.#beginSolve(); } - /** Answers the dialog: `keepBest` defends the layout already on screen. */ + /** + * Answers the dialog: `keepBest` defends the layout already on screen. + * + * Discarding deletes it here, from the screen and from storage, before the + * run starts rather than when its result lands. A layout the player chose + * to throw away should not stay on the board through a 20s run, and a run + * stopped before it reports anything would otherwise hand it back as if + * nothing had been chosen. + */ startSolve(keepBest: boolean) { this.activeModal = null; + if (!keepBest) solverState.clearResult(); this.#beginSolve({ keepBest }); } @@ -1056,8 +1065,9 @@ class UIState { this.isImporting = true; this.importError = null; try { - // The run belongs to a board and a roster this is about to replace. - solverState.stopOptimizer(); + // The run belongs to a board and a roster this is about to replace, so + // it has to have landed before either changes — see `settleOptimizer`. + await solverState.settleOptimizer(); configState.applyGameImport( applyInProgress(resolved, this.gameImportMode), resolved.anomalyId, @@ -1067,11 +1077,12 @@ class UIState { configState.buildingUpgrades, resolved.lockedTemplateIds, ); + // Every stored solve was found under the old roster: keep each one the + // import left buildable, re-scored, before restore() reads the store. + await solverState.carryOverSolves(resolved.lockedTemplateIds); this.activeModal = null; this.importCode = ""; this.gameImport = null; - // The board on screen may have been replaced, and every stored solve was - // found under the old roster — restore() hangs the right one, as stale. solverState.restore(); this.closeInspector(); this.recenterCanvas(); diff --git a/src/lib/storage/storage.ts b/src/lib/storage/storage.ts index 7b7ec9f..70542e5 100755 --- a/src/lib/storage/storage.ts +++ b/src/lib/storage/storage.ts @@ -334,6 +334,9 @@ export const solverStorage = { setItem(KEYS.SOLVE, records); }, + /** Every island that has a stored solve. */ + solveIds: (): string[] => Object.keys(loadSolveRecords()), + /** Forgets one island's solve, or the whole store when it empties. */ clearSolve: (templateId: string): void => { const records = loadSolveRecords(); diff --git a/src/lib/worker/solveModes.test.ts b/src/lib/worker/solveModes.test.ts index ee82835..84b225b 100755 --- a/src/lib/worker/solveModes.test.ts +++ b/src/lib/worker/solveModes.test.ts @@ -1,7 +1,7 @@ /** * What a run mode costs in wall-clock. * - * The figure on the mode control is the one thing that makes "10 × 10s" a + * The figure on the mode control is the one thing that makes "5 × 20s" a * decision rather than a guess, and it is only honest if it models the pool the * coordinator actually schedules onto. These cases pin the two ends of that: * a pool wide enough to overlap the attempts, and one that is not. @@ -17,63 +17,86 @@ import { describe("taskDurationsMs", () => { it("splits an attempt's budget across islands by tile count squared", () => { // 900 : 100, where a linear split would give 3 : 1. See `islandBudgetsS`. - const durations = taskDurationsMs(SOLVE_MODES.quick, [30, 10]); + // On one worker the run queues, so the slices have to sum to the budget. + const durations = taskDurationsMs(SOLVE_MODES.quick, [30, 10], 1); expect(durations).toEqual([27_000, 3_000]); }); + /* + * With a worker per task the islands run side by side and the run lasts as + * long as its largest slice, so the slices are stretched until that one is + * the whole budget: a "30s" run takes 30s, and every island gets more. + */ + it("stretches the split to the whole budget when the run fits the pool", () => { + const [big, small] = taskDurationsMs(SOLVE_MODES.quick, [30, 10], 8); + expect(big).toBe(30_000); + expect(small).toBeCloseTo(30_000 / 9); + expect( + estimateMakespanMs(taskDurationsMs(SOLVE_MODES.quick, [30, 10], 8), 8), + ).toBe(30_000); + }); + + it("leaves the split alone once the run has to queue", () => { + // 5 attempts x 2 islands is ten tasks for eight workers. + expect(taskDurationsMs(SOLVE_MODES.deep, [30, 10], 8).slice(0, 2)).toEqual([ + 18_000, 2_000, + ]); + }); + it("repeats the split once per attempt, attempt-major", () => { // Attempt-major is what lets an early Stop still leave every island // solved; island-major would starve the last one. const durations = taskDurationsMs( { ...SOLVE_MODES.deep, attempts: 3 }, [1, 1], + 1, ); - expect(durations).toEqual([5_000, 5_000, 5_000, 5_000, 5_000, 5_000]); + expect(durations).toEqual([10_000, 10_000, 10_000, 10_000, 10_000, 10_000]); }); it("survives a board with no buildable tiles at all", () => { - expect(taskDurationsMs(SOLVE_MODES.quick, [0])).toEqual([0]); + expect(taskDurationsMs(SOLVE_MODES.quick, [0], 8)).toEqual([0]); }); }); describe("estimateMakespanMs", () => { it("is the single task's own budget when there is only one", () => { expect( - estimateMakespanMs(taskDurationsMs(SOLVE_MODES.quick, [10]), 8), + estimateMakespanMs(taskDurationsMs(SOLVE_MODES.quick, [10], 8), 8), ).toBe(30_000); }); it("overlaps a deep run's attempts across the pool", () => { - // Ten 10s attempts of one island on seven workers: two rounds, because the - // eighth task has to wait for a worker to come free. - expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10]), 7)).toBe( - 20_000, - ); + // Five 20s attempts of one island on four workers: two rounds, because the + // fifth task has to wait for a worker to come free. + expect( + estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10], 4), 4), + ).toBe(40_000); }); it("charges a single-worker browser the full serial price", () => { - expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10]), 1)).toBe( - 100_000, - ); - expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10]), 1)).toBe( - 500_000, - ); + expect( + estimateMakespanMs(taskDurationsMs(SOLVE_MODES.deep, [10], 1), 1), + ).toBe(100_000); + expect( + estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10], 1), 1), + ).toBe(500_000); }); - it("keeps a fifty-attempt run to eight rounds on seven workers", () => { - // 50 tasks over 7 workers is 7 apiece with one left over, so one worker - // runs an eighth — 80s, not the 500s the mode's own arithmetic suggests. - expect(estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10]), 7)).toBe( - 80_000, - ); + it("keeps a twenty-five-attempt run to four rounds on seven workers", () => { + // 25 tasks over 7 workers is 3 apiece with four left over, so four workers + // run a fourth — 80s, not the 500s the mode's own arithmetic suggests. + expect( + estimateMakespanMs(taskDurationsMs(SOLVE_MODES.max, [10], 7), 7), + ).toBe(80_000); }); it("never reports less than the longest single task", () => { // An island that owns most of the board gets most of every attempt's // budget, and no amount of parallelism makes that one search shorter. The // two scraps are held at the quarter-second floor, so it gets the rest. - const durations = taskDurationsMs(SOLVE_MODES.quick, [90, 5, 5]); - expect(estimateMakespanMs(durations, 8)).toBe(29_500); + const durations = taskDurationsMs(SOLVE_MODES.quick, [90, 5, 5], 2); + expect(estimateMakespanMs(durations, 2)).toBe(29_500); }); it("is zero when there is nothing to schedule", () => { @@ -93,7 +116,7 @@ describe("solveModeFor", () => { expect(deep.attemptBudgetMs).toBeGreaterThan( SOLVE_MODES.deep.attemptBudgetMs, ); - expect(deep.shape).toBe(`10 × ${deep.attemptBudgetMs / 1000}s`); + expect(deep.shape).toBe(`5 × ${deep.attemptBudgetMs / 1000}s`); }); it("reads an unknown id as the base rules, as getAnomaly does", () => { diff --git a/src/lib/worker/solveModes.ts b/src/lib/worker/solveModes.ts index deef54d..d8e3d62 100755 --- a/src/lib/worker/solveModes.ts +++ b/src/lib/worker/solveModes.ts @@ -5,11 +5,20 @@ * deterministic — so a single 30s walk is one sample from a distribution, and * a long walk is not worth ten times a short one. Past the first few seconds * the annealing temperature has fallen far enough that it mostly polishes the - * basin it is already in. Ten independent 10s walks start from ten different - * random streams, and the best of ten samples beats one longer sample often + * basin it is already in. Independent walks start from different random + * streams, and the best of several samples beats one longer sample often * enough that it is worth offering as a choice rather than picking for the * user. * + * Not too short, though. A walk on a large island is still climbing well past + * 10s on a late roster, and `npm run survey` over all eight maps puts the + * average share of the bound at 91.7% for a 10s walk, 92.9% at 20s and 93.6% + * at 40s (full unlocks less cooler8 and ecto_compressor, no anomaly), and + * 95.3% against 96.1% at full unlocks. One 20s walk matches or beats the best + * of three 10s ones on both rosters, at two-thirds of the time — so at a fixed + * cost, five 20s walks beat ten 10s ones. The gain is on the large maps + * (147-180 tiles, 1-3 points); maps 1-3 sit where they will stay at 10s. + * * It costs more wall-clock, which is why it is a choice at all. How much more * depends on the machine: the attempts are independent tasks and go through * the same worker pool the islands do, so a machine with cores to spare runs @@ -18,7 +27,12 @@ * the button is pressed rather than after. */ -import { getAnomaly, islandBudgetsS, type AnomalyId } from "@reactor2/solver"; +import { + fitBudgetsToPool, + getAnomaly, + islandBudgetsS, + type AnomalyId, +} from "@reactor2/solver"; export type SolveModeId = "quick" | "deep" | "max"; @@ -26,7 +40,7 @@ export interface SolveMode { id: SolveModeId; /** Short name for the control. */ label: string; - /** What the run actually does, e.g. "10 × 10s". */ + /** What the run actually does, e.g. "5 × 20s". */ shape: string; /** * How many independent searches **each island** gets. Their best is kept, @@ -54,26 +68,27 @@ export const SOLVE_MODES: Record = { deep: { id: "deep", label: "Deep", - shape: "10 × 10s", - attempts: 10, - attemptBudgetMs: 10_000, + shape: "5 × 20s", + attempts: 5, + attemptBudgetMs: 20_000, }, /** - * The same 10s attempt as `deep`, five times over. + * The same 20s attempt as `deep`, five times over. * - * Sampling has diminishing returns — the best of fifty draws beats the best - * of ten by much less than ten beat one — so this is the mode for a board - * being settled rather than explored, and it is priced accordingly. It earns - * its place mostly on the shortlist: fifty walks past the same optimum find - * far more of the arrangements that tie it than ten do, and which of those - * is nicest to build is the one judgement the solver cannot make. + * Sampling has diminishing returns — the best of twenty-five draws beats the + * best of five by much less than five beat one — so this is the mode for a + * board being settled rather than explored, and it is priced accordingly. It + * earns its place mostly on the shortlist: more walks past the same optimum + * find more of the arrangements that tie it, and which of those is nicest to + * build is the one judgement the solver cannot make. It was fifty 10s walks + * at the same cost, which found more ties and less power. */ max: { id: "max", label: "Max", - shape: "50 × 10s", - attempts: 50, - attemptBudgetMs: 10_000, + shape: "25 × 20s", + attempts: 25, + attemptBudgetMs: 20_000, }, }; @@ -94,16 +109,33 @@ export const DEFAULT_SOLVE_MODE: SolveModeId = "quick"; * unlocks it comes back 1.2% *below* them. 20s recovers nearly all of it; 30s * and 60s add a few tenths more. Best of ten 10s walks beats one 10s walk, but * best of ten 20s walks beats it again by 1-2 points on every roster, so the - * attempt is lengthened rather than the count raised. Doubling gives Deep and - * Max their 20s, and Quick goes along so a mode keeps one meaning. + * attempt is lengthened rather than the count raised. Those figures were taken + * against 10s attempts; doubling now gives Deep and Max 40s, and Quick goes + * along so a mode keeps one meaning. + * + * Both Tidals were measured too and gain no more from a longer walk than the + * base rules do (legacy ×1.67: 90.2% / 91.5% / 91.9% of the bound at 10 / 20 / + * 40s), so the longer default covers them and they take no factor here. */ -const ATTEMPT_BUDGET_SCALE: Partial> = { - singularity_isolation: 2, +const ATTEMPT_BUDGET_SCALE: Partial< + Record +> = { + singularity_isolation: { + scale: 2, + reason: + "A generator's output depends on its neighbours there, so a walk needs " + + "the extra time to find layouts that keep them apart.", + }, }; /** The factor `solveModeFor` applies under `anomalyId`; 1 under most rules. */ export function attemptBudgetScale(anomalyId: string): number { - return ATTEMPT_BUDGET_SCALE[getAnomaly(anomalyId).id] ?? 1; + return ATTEMPT_BUDGET_SCALE[getAnomaly(anomalyId).id]?.scale ?? 1; +} + +/** Why `attemptBudgetScale` is above 1 under `anomalyId`, for the mode dialog. */ +export function attemptBudgetReason(anomalyId: string): string { + return ATTEMPT_BUDGET_SCALE[getAnomaly(anomalyId).id]?.reason ?? ""; } /** @@ -166,15 +198,20 @@ export function estimateMakespanMs( * Shared by the estimate and by the coordinator's own ordering, so the figure * on the button cannot describe a schedule the run does not use. The split * within an attempt is `islandBudgetsS`, the one `planSolve` hands the - * coordinator, for the same reason. + * coordinator, stretched by `fitBudgetsToPool` as the coordinator stretches + * it, for the same reason. */ export function taskDurationsMs( mode: SolveMode, islandTileCounts: readonly number[], + poolSize: number, ): number[] { - const perAttempt = islandBudgetsS( - islandTileCounts, - mode.attemptBudgetMs / 1000, + const attemptS = mode.attemptBudgetMs / 1000; + const perAttempt = fitBudgetsToPool( + islandBudgetsS(islandTileCounts, attemptS), + attemptS, + mode.attempts, + poolSize, ).map((s) => s * 1000); const durations: number[] = []; for (let attempt = 0; attempt < mode.attempts; attempt++) { diff --git a/src/lib/worker/solverCoordinator.test.ts b/src/lib/worker/solverCoordinator.test.ts index 95e6aad..d21e4d0 100755 --- a/src/lib/worker/solverCoordinator.test.ts +++ b/src/lib/worker/solverCoordinator.test.ts @@ -42,7 +42,17 @@ const dispatched: IslandWorkerRequest[] = []; * power the caller chooses per task id, so an "attempt" can be made to win, * tie or lose deterministically. */ -function installFakeWorker(powerFor: (taskId: string) => number) { +function installFakeWorker( + powerFor: (taskId: string) => number, + /** + * Progress powers each task streams, all at once, before it finishes — + * and how long after that it finishes. Omitted, a task only finishes. + */ + stream: { progress: number[]; doneAfterMs: number } = { + progress: [], + doneAfterMs: 0, + }, +) { const listeners = new Map 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 e8a1e81..c329a4f 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); } @@ -168,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 = ( @@ -185,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.