diff --git a/CLAUDE.md b/CLAUDE.md index 1cdb418..621a9ed 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ step and `npm run build:solver` exists only so the package stays publishable Three things worth knowing when either starts complaining: -- **`BUILDING_TABLE` is `as const`**, so its element type is a union of 38 +- **`BUILDING_TABLE` is `as const`**, so its element type is a union of 41 literal object types and `.find()` over that union hides any property some member lacks. `packages/solver/src/data/buildings.ts` exports it through a `readonly BuildingDefinition[]` annotation so consumers get the uniform diff --git a/docs/SOLVER.md b/docs/SOLVER.md index b59fa5f..cd7f208 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -321,6 +321,17 @@ cooler term goes, because adjacency does: the pool reaches the whole island, so be a reactor. The two-class bound takes one cap on the _best_ of each class, since which class a tile falls in is a property of that tile and an inland generator may have shore reactors around it. +**A cooler reaches eight producers, and the cooling budget forgot it.** The LP charges cooling as one +island-wide budget, `0.25 · H + wMin · D <= nCool · C_top`, which is exact in the _amount_ and blind to +the _reach_: cooling gets to a producer only from a cooler beside it, so `nCool` coolers keep at most +`8 · nCool` producers online. That was harmless while a cooler covered two or three producers' waste. +Cooler #8 against Generator #7 covers fourteen, and the budget asked for five coolers on Magma Rift's +107 tiles where the best layout found uses thirteen at a third of their capacity — the base-rules bound +sat 3–5% above it on every shipped map and read 92–94% for layouts at 96–98% of the corrected figure. +The LP now caps generators at `8 · nCool` per split (and direct producers too); under a pool the cooling +is not adjacent and neither is the cap. Singularity's bound does not move: its isolated generators are +few enough that eight per cooler never binds. + **The second half of the same argument is how many generators an island can keep apart, and a generator-bound roster is what runs into it.** Rating every generator at the bonus is what a search free to spread them out can reach — but isolated generators are pairwise non-adjacent by @@ -776,6 +787,9 @@ about before changing anything in the search: exhaustive brute force rather than by running the solver and writing down what it said. **These numbers must never go down.** Each size is checked twice: once against the simulator, pinning the exact layout deterministically, and once against the search, which must reproduce it. + That ladder is a mid-game roster, where cooling binds; **`goldenLayoutsFullTier.test.ts`** is + the same seven islands with every building at its top tier, where cooler8 makes cooling + plentiful and generator capacity binds instead, so the optima are a different ladder. - **`anomalies.test.ts`**, **`scaling.test.ts`** and **`placementSearch.rating.test.ts`** — the rules, which the fixtures cannot reach at all because every fixture is solved under the base ones. Between them they pin each rule reaching the layout the search reports, the search running diff --git a/packages/solver/CLAUDE.md b/packages/solver/CLAUDE.md index 6371107..a95312b 100644 --- a/packages/solver/CLAUDE.md +++ b/packages/solver/CLAUDE.md @@ -689,7 +689,9 @@ Start with `npm test`. Four parts of it are what actually catch a regression: correctly rounded and a V8 upgrade can move one by an ULP — enough to diverge the walk permanently. - **`tests/goldenLayouts.test.ts`** pins the proven optima for islands of 3–9 - tiles. **These numbers must never go down.** + tiles on a mid-game roster, and **`tests/goldenLayoutsFullTier.test.ts`** the + same islands with every building at its top tier. **These numbers must never + go down.** - **`tests/distribution.test.ts`** pins the flow rules against measurements from the live game and against an independent max-flow oracle. - **The anomaly suite** — `tests/anomalies.test.ts`, `tests/scaling.test.ts`, diff --git a/packages/solver/README.md b/packages/solver/README.md index c9f19b8..bd5341f 100644 --- a/packages/solver/README.md +++ b/packages/solver/README.md @@ -76,7 +76,8 @@ there is no build step between an edit and a reload. Before changing the search, read [`docs/SOLVER.md`](../../docs/SOLVER.md) and [`docs/game-logic.md`](../../docs/game-logic.md). Two things in particular are easy to break without noticing: the RNG's pinned golden streams, and the proven -optima in `tests/goldenLayouts.test.ts`, **which must never go down**. +optima in `tests/goldenLayouts.test.ts` and `tests/goldenLayoutsFullTier.test.ts`, +**which must never go down**. ## License diff --git a/packages/solver/fixtures/full_upgrades_seed1.json b/packages/solver/fixtures/full_upgrades_seed1.json index d94b211..a11f8c0 100755 --- a/packages/solver/fixtures/full_upgrades_seed1.json +++ b/packages/solver/fixtures/full_upgrades_seed1.json @@ -21,9 +21,11 @@ "cooler4": 7, "cooler5": 8, "cooler6": 10, - "cooler7": 6, + "cooler7": 8, + "cooler8": 1, "divine_reactor": 5, "doomStar_reactor": 5, + "ecto_compressor": 5, "flux_reactor": 5, "fusion_reactor": 5, "gas_plant": 5, @@ -33,7 +35,7 @@ "generator4": 5, "generator5": 8, "generator6": 8, - "generator7": 4, + "generator7": 7, "geothermal_plant": 5, "gravitron_plant": 5, "heliothermal_plant": 5, @@ -41,6 +43,7 @@ "hyper_space_core": 5, "neuro_grid_reactor": 5, "nuclear_reactor": 5, + "precursor_pyramid": 5, "psionic_tower": 5, "quantum_reactor": 5, "sauron_eye": 5, @@ -54,75 +57,70 @@ { "x": 1, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 2, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 3, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 0, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 1, "y": 1, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 2, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 3, "y": 1, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 0, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 1, "y": 2, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 2, "y": 2, - "building_id": "doomStar_reactor" - }, - { - "x": 3, - "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 0, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "precursor_pyramid" }, { "x": 1, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 2, "y": 3, - "building_id": "cooler7" + "building_id": "generator7" } ], - "power_output": 2.66e+22, + "power_output": 6.71830985915493e+23, "island_count": 1 }, "annealed": { @@ -130,85 +128,85 @@ { "x": 0, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 1, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 2, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 3, "y": 0, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 0, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 1, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 2, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 3, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 0, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 1, "y": 2, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 2, "y": 2, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 3, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 0, "y": 3, - "building_id": "generator5" + "building_id": "ecto_compressor" }, { "x": 1, "y": 3, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 2, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "cooler8" }, { "x": 3, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "generator7" } ], - "power_output": 2.7050847458757064e+22, + "power_output": 9.540000000000001e+23, "island_count": 1 } } diff --git a/packages/solver/fixtures/no_buildable_tiles.json b/packages/solver/fixtures/no_buildable_tiles.json index 202f179..800198e 100755 --- a/packages/solver/fixtures/no_buildable_tiles.json +++ b/packages/solver/fixtures/no_buildable_tiles.json @@ -21,9 +21,11 @@ "cooler4": 7, "cooler5": 8, "cooler6": 10, - "cooler7": 6, + "cooler7": 8, + "cooler8": 1, "divine_reactor": 5, "doomStar_reactor": 5, + "ecto_compressor": 5, "flux_reactor": 5, "fusion_reactor": 5, "gas_plant": 5, @@ -33,7 +35,7 @@ "generator4": 5, "generator5": 8, "generator6": 8, - "generator7": 4, + "generator7": 7, "geothermal_plant": 5, "gravitron_plant": 5, "heliothermal_plant": 5, @@ -41,6 +43,7 @@ "hyper_space_core": 5, "neuro_grid_reactor": 5, "nuclear_reactor": 5, + "precursor_pyramid": 5, "psionic_tower": 5, "quantum_reactor": 5, "sauron_eye": 5, diff --git a/packages/solver/fixtures/single_tile.json b/packages/solver/fixtures/single_tile.json index 7c23e7d..e136f91 100755 --- a/packages/solver/fixtures/single_tile.json +++ b/packages/solver/fixtures/single_tile.json @@ -21,9 +21,11 @@ "cooler4": 7, "cooler5": 8, "cooler6": 10, - "cooler7": 6, + "cooler7": 8, + "cooler8": 1, "divine_reactor": 5, "doomStar_reactor": 5, + "ecto_compressor": 5, "flux_reactor": 5, "fusion_reactor": 5, "gas_plant": 5, @@ -33,7 +35,7 @@ "generator4": 5, "generator5": 8, "generator6": 8, - "generator7": 4, + "generator7": 7, "geothermal_plant": 5, "gravitron_plant": 5, "heliothermal_plant": 5, @@ -41,6 +43,7 @@ "hyper_space_core": 5, "neuro_grid_reactor": 5, "nuclear_reactor": 5, + "precursor_pyramid": 5, "psionic_tower": 5, "quantum_reactor": 5, "sauron_eye": 5, diff --git a/packages/solver/fixtures/small_grid_seed42.json b/packages/solver/fixtures/small_grid_seed42.json index 4fa05a2..bdbcdee 100755 --- a/packages/solver/fixtures/small_grid_seed42.json +++ b/packages/solver/fixtures/small_grid_seed42.json @@ -21,9 +21,11 @@ "cooler4": 7, "cooler5": 8, "cooler6": 10, - "cooler7": 6, + "cooler7": 8, + "cooler8": 1, "divine_reactor": 5, "doomStar_reactor": 5, + "ecto_compressor": 5, "flux_reactor": 5, "fusion_reactor": 5, "gas_plant": 5, @@ -33,7 +35,7 @@ "generator4": 5, "generator5": 8, "generator6": 8, - "generator7": 4, + "generator7": 7, "geothermal_plant": 5, "gravitron_plant": 5, "heliothermal_plant": 5, @@ -41,6 +43,7 @@ "hyper_space_core": 5, "neuro_grid_reactor": 5, "nuclear_reactor": 5, + "precursor_pyramid": 5, "psionic_tower": 5, "quantum_reactor": 5, "sauron_eye": 5, @@ -54,45 +57,40 @@ { "x": 0, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { - "x": 2, + "x": 1, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 0, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 1, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 2, "y": 1, - "building_id": "wind_turbine" - }, - { - "x": 0, - "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 1, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 2, "y": 2, - "building_id": "cooler7" + "building_id": "ecto_compressor" } ], - "power_output": 1.33e+22, + "power_output": 4.24e+23, "island_count": 1 }, "annealed": { @@ -100,37 +98,37 @@ { "x": 0, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 1, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 2, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 0, "y": 1, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 1, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 2, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 0, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 1, @@ -140,10 +138,10 @@ { "x": 2, "y": 2, - "building_id": "neuro_grid_reactor" + "building_id": "generator7" } ], - "power_output": 1.3578323163841808e+22, + "power_output": 5.3e+23, "island_count": 1 } } diff --git a/packages/solver/fixtures/three_island_uneven.json b/packages/solver/fixtures/three_island_uneven.json index 94f7838..8083490 100755 --- a/packages/solver/fixtures/three_island_uneven.json +++ b/packages/solver/fixtures/three_island_uneven.json @@ -21,9 +21,11 @@ "cooler4": 7, "cooler5": 8, "cooler6": 10, - "cooler7": 6, + "cooler7": 8, + "cooler8": 1, "divine_reactor": 5, "doomStar_reactor": 5, + "ecto_compressor": 5, "flux_reactor": 5, "fusion_reactor": 5, "gas_plant": 5, @@ -33,7 +35,7 @@ "generator4": 5, "generator5": 8, "generator6": 8, - "generator7": 4, + "generator7": 7, "geothermal_plant": 5, "gravitron_plant": 5, "heliothermal_plant": 5, @@ -41,6 +43,7 @@ "hyper_space_core": 5, "neuro_grid_reactor": 5, "nuclear_reactor": 5, + "precursor_pyramid": 5, "psionic_tower": 5, "quantum_reactor": 5, "sauron_eye": 5, @@ -54,42 +57,47 @@ { "x": 0, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { - "x": 1, + "x": 3, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 4, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 5, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { - "x": 6, + "x": 8, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 9, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "cooler8" }, { "x": 10, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 11, "y": 0, - "building_id": "cooler7" + "building_id": "cooler8" + }, + { + "x": 12, + "y": 0, + "building_id": "wind_turbine" }, { "x": 0, @@ -99,17 +107,17 @@ { "x": 1, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "cooler8" }, { "x": 3, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 4, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "cooler8" }, { "x": 5, @@ -119,17 +127,17 @@ { "x": 6, "y": 1, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 8, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "precursor_pyramid" }, { "x": 9, "y": 1, - "building_id": "cooler7" + "building_id": "ecto_compressor" }, { "x": 10, @@ -139,17 +147,17 @@ { "x": 11, "y": 1, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 12, "y": 1, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 3, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 4, @@ -159,75 +167,70 @@ { "x": 5, "y": 2, - "building_id": "cooler7" + "building_id": "cooler8" }, { "x": 6, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 8, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 9, "y": 2, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 10, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 11, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 12, "y": 2, - "building_id": "generator7" - }, - { - "x": 4, - "y": 3, - "building_id": "doomStar_reactor" + "building_id": "cooler8" }, { "x": 5, "y": 3, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 8, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "precursor_pyramid" }, { "x": 9, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 10, "y": 3, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 11, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "precursor_pyramid" }, { "x": 12, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "generator7" } ], - "power_output": 6.402033898305084e+22, + "power_output": 1.4735492957746475e+24, "island_count": 3 }, "annealed": { @@ -235,52 +238,57 @@ { "x": 0, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 1, "y": 0, - "building_id": "cooler7" + "building_id": "cooler8" }, { "x": 3, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 4, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 5, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 6, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 8, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 9, "y": 0, - "building_id": "cooler7" + "building_id": "ecto_compressor" }, { "x": 10, "y": 0, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 11, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" + }, + { + "x": 12, + "y": 0, + "building_id": "cooler8" }, { "x": 0, @@ -290,135 +298,135 @@ { "x": 1, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 3, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 4, "y": 1, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 5, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "cooler8" }, { "x": 6, "y": 1, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 8, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 9, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 10, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 11, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 12, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 3, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 4, "y": 2, - "building_id": "cooler7" + "building_id": "cooler8" }, { "x": 5, "y": 2, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 6, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 8, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 9, "y": 2, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 10, "y": 2, - "building_id": "cooler7" + "building_id": "ecto_compressor" }, { "x": 11, "y": 2, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 12, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 4, "y": 3, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 5, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 8, "y": 3, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 9, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 10, "y": 3, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 11, "y": 3, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 12, "y": 3, - "building_id": "doomStar_reactor" + "building_id": "generator7" } ], - "power_output": 6.424576271186441e+22, + "power_output": 2.2260000000000003e+24, "island_count": 3 } } diff --git a/packages/solver/fixtures/two_island_seed7.json b/packages/solver/fixtures/two_island_seed7.json index 8826015..10f41dc 100755 --- a/packages/solver/fixtures/two_island_seed7.json +++ b/packages/solver/fixtures/two_island_seed7.json @@ -21,9 +21,11 @@ "cooler4": 7, "cooler5": 8, "cooler6": 10, - "cooler7": 6, + "cooler7": 8, + "cooler8": 1, "divine_reactor": 5, "doomStar_reactor": 5, + "ecto_compressor": 5, "flux_reactor": 5, "fusion_reactor": 5, "gas_plant": 5, @@ -33,7 +35,7 @@ "generator4": 5, "generator5": 8, "generator6": 8, - "generator7": 4, + "generator7": 7, "geothermal_plant": 5, "gravitron_plant": 5, "heliothermal_plant": 5, @@ -41,6 +43,7 @@ "hyper_space_core": 5, "neuro_grid_reactor": 5, "nuclear_reactor": 5, + "precursor_pyramid": 5, "psionic_tower": 5, "quantum_reactor": 5, "sauron_eye": 5, @@ -54,85 +57,75 @@ { "x": 0, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { - "x": 2, + "x": 1, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 4, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { - "x": 6, + "x": 5, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 0, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 1, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 2, "y": 1, - "building_id": "wind_turbine" + "building_id": "generator7" }, { "x": 4, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 5, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 6, "y": 1, - "building_id": "wind_turbine" - }, - { - "x": 0, - "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 1, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 2, "y": 2, - "building_id": "cooler7" - }, - { - "x": 4, - "y": 2, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 5, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 6, "y": 2, - "building_id": "cooler7" + "building_id": "ecto_compressor" } ], - "power_output": 2.66e+22, + "power_output": 8.48e+23, "island_count": 2 }, "annealed": { @@ -140,27 +133,27 @@ { "x": 0, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 1, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 2, "y": 0, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 4, "y": 0, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 5, "y": 0, - "building_id": "cooler7" + "building_id": "ecto_compressor" }, { "x": 6, @@ -170,65 +163,65 @@ { "x": 0, "y": 1, - "building_id": "generator7" + "building_id": "ecto_compressor" }, { "x": 1, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 2, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 4, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 5, "y": 1, - "building_id": "generator7" + "building_id": "cooler8" }, { "x": 6, "y": 1, - "building_id": "doomStar_reactor" + "building_id": "ecto_compressor" }, { "x": 0, "y": 2, - "building_id": "cooler7" + "building_id": "generator7" }, { "x": 1, "y": 2, - "building_id": "cooler7" + "building_id": "ecto_compressor" }, { "x": 2, "y": 2, - "building_id": "black_hole_reactor" + "building_id": "generator7" }, { "x": 4, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" }, { "x": 5, "y": 2, - "building_id": "sauron_eye" + "building_id": "generator7" }, { "x": 6, "y": 2, - "building_id": "doomStar_reactor" + "building_id": "generator7" } ], - "power_output": 2.705745991225311e+22, + "power_output": 1.06e+24, "island_count": 2 } } diff --git a/packages/solver/src/data/buildings.ts b/packages/solver/src/data/buildings.ts index 308e7a7..bb0739a 100755 --- a/packages/solver/src/data/buildings.ts +++ b/packages/solver/src/data/buildings.ts @@ -183,8 +183,18 @@ const BUILDING_TABLE = [ { cooling: 7.26e20 }, { cooling: 1.31e21 }, { cooling: 2.35e21 }, + { cooling: 4.24e21 }, + { cooling: 7.62e21 }, ], }, + { + id: "cooler8", + type: "cooler", + name: "Heat Sink #8", + price: 1.67e32, + displayIndex: 8, + levels: [{ cooling: 2.83e23 }, { cooling: 5.1e23 }], + }, { id: "generator", type: "generator", @@ -316,6 +326,9 @@ const BUILDING_TABLE = [ { heat: 4.43e21, energy: 3.32e21, waste: 1.11e21 }, { heat: 8.85e21, energy: 6.64e21, waste: 2.21e21 }, { heat: 1.77e22, energy: 1.33e22, waste: 4.4e21 }, + { heat: 3.54e22, energy: 2.66e22, waste: 8.8e21 }, + { heat: 7.08e22, energy: 5.31e22, waste: 1.77e22 }, + { heat: 1.42e23, energy: 1.06e23, waste: 3.6e22 }, ], }, { @@ -677,6 +690,36 @@ const BUILDING_TABLE = [ { heat: 4.5e21 }, ], }, + { + id: "precursor_pyramid", + type: "reactor", + name: "Precursor Pyramid", + price: 3.72e31, + displayIndex: 0, + levels: [ + { heat: 1.18e22 }, + { heat: 1.48e22 }, + { heat: 1.84e22 }, + { heat: 2.31e22 }, + { heat: 2.88e22 }, + { heat: 3.6e22 }, + ], + }, + { + id: "ecto_compressor", + type: "reactor", + name: "Ecto Compressor", + price: 6.69e32, + displayIndex: 0, + levels: [ + { heat: 9.44e22 }, + { heat: 1.18e23 }, + { heat: 1.48e23 }, + { heat: 1.84e23 }, + { heat: 2.31e23 }, + { heat: 2.88e23 }, + ], + }, ] as const satisfies readonly BuildingDefinition[]; /** diff --git a/packages/solver/src/encoding/blueprint.ts b/packages/solver/src/encoding/blueprint.ts index fece1c2..0eb8dad 100755 --- a/packages/solver/src/encoding/blueprint.ts +++ b/packages/solver/src/encoding/blueprint.ts @@ -187,7 +187,7 @@ const TILE_BYTE_MAP: Record = { }; const BUILDING_BYTE_MAP: Record = { - // Coolers (10-16) + // Coolers (10-17) cooler1: 10, cooler2: 11, cooler3: 12, @@ -195,6 +195,7 @@ const BUILDING_BYTE_MAP: Record = { cooler5: 14, cooler6: 15, cooler7: 16, + cooler8: 17, // Generators (20-26) generator: 20, @@ -205,7 +206,7 @@ const BUILDING_BYTE_MAP: Record = { generator6: 25, generator7: 26, - // Heat Producers (30-53) + // Heat Producers (30-55) wind_turbine: 30, solar_panel: 31, coal_plant: 32, @@ -230,6 +231,8 @@ const BUILDING_BYTE_MAP: Record = { neuro_grid_reactor: 51, flux_reactor: 52, doomStar_reactor: 53, + precursor_pyramid: 54, + ecto_compressor: 55, }; const REV_TILE_BYTE_MAP: Record = Object.fromEntries( diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index 6c3ae23..62f7e66 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -470,11 +470,23 @@ function estimateIslandMaxPower( let best = 0.0; const maxEngine = canHub ? buildableTiles : 0; + // A producer counts only if cooling covers its waste, and cooling reaches a + // producer only from a cooler beside it — so every online generator and + // direct producer stands next to a cooler, and a cooler has eight + // neighbours: `nCool` coolers can keep at most `8 * nCool` producers online. + // The cooling *budget* above relaxes that away, and it stops being harmless + // once one cooler can cover many producers' waste: cooler8 against + // generator7 covers fourteen, the budget asks for five coolers on Magma + // Rift's 107 tiles, and the best layout found uses thirteen at a third of + // their capacity. Counting the reach took 3-5% off the base-rules bound on + // every shipped map at full unlocks. Under a pool the cooling is not + // adjacent, and neither is this. + const reach = pooledCooling ? Infinity : maxNeighbourTiles(buildableTiles); for (let nEngine = 0; nEngine <= maxEngine; nEngine++) { - const heatCap = bestHeatForEngineTiles(nEngine, rVal, capacities); + const heatCapAll = bestHeatForEngineTiles(nEngine, rVal, capacities); // Engine tiles that can't form a reactor+generator pair. - if (nEngine > 0 && heatCap <= 0.0) continue; + if (nEngine > 0 && heatCapAll <= 0.0) continue; const maxDp = canDp ? buildableTiles - nEngine : 0; for (let nDp = 0; nDp <= maxDp; nDp++) { @@ -482,6 +494,12 @@ function estimateIslandMaxPower( // Nothing runs on an island with zero coolers. if (nCool < 1) continue; const cooling = nCool * cVal; + const reached = nCool * reach; + const heatCap = + reached >= nEngine + ? heatCapAll + : bestHeatForEngineTiles(nEngine, rVal, capacities, reached); + const dpCap = Math.min(nDp, reached); // Maximize 0.75*H + dpNet*D under the shared cooling budget: a // two-variable LP whose optimum sits at one of the two "fill one side @@ -489,11 +507,12 @@ function estimateIslandMaxPower( const hFirst = Math.min(heatCap, cooling / wasteRatio); const dRest = dpWaste <= EPS - ? nDp - : Math.min(nDp, (cooling - hFirst * wasteRatio) / dpWaste); + ? dpCap + : Math.min(dpCap, (cooling - hFirst * wasteRatio) / dpWaste); const pHeatFirst = hFirst * energyRatio + dpNet * dRest; - const dFirst = dpWaste <= EPS ? nDp : Math.min(nDp, cooling / dpWaste); + const dFirst = + dpWaste <= EPS ? dpCap : Math.min(dpCap, cooling / dpWaste); const hRest = Math.min( heatCap, (cooling - dpWaste * dFirst) / wasteRatio, @@ -771,11 +790,13 @@ function bestHeatForEngineTiles( nEngine: number, rVal: number, capacities: Float64Array, + maxGenerators = Infinity, ): number { if (nEngine < 2 || rVal <= 0) return 0.0; let best = 0.0; - for (let nReact = 1; nReact <= nEngine - 1; nReact++) { + const fewestReactors = Math.max(1, nEngine - maxGenerators); + for (let nReact = fewestReactors; nReact <= nEngine - 1; nReact++) { const heat = Math.min(nReact * rVal, capacities[nEngine - nReact]); if (heat > best) best = heat; } diff --git a/packages/solver/tests/goldenLayouts.test.ts b/packages/solver/tests/goldenLayouts.test.ts index 928d0d0..10c0972 100644 --- a/packages/solver/tests/goldenLayouts.test.ts +++ b/packages/solver/tests/goldenLayouts.test.ts @@ -69,12 +69,25 @@ import { expectClose, placementsByPos } from "./helpers"; const { hillClimb, offlineProducers, pruneDeadWeight } = internals; -/** The scenario: everything unlocked EXCEPT these four. */ +/** + * The scenario: a mid-game player — everything unlocked EXCEPT the late game. + * + * The first four are the scenario as it was brute-forced. The rest are + * late-game buildings the game added since, every one stronger than anything + * the scenario holds; they join this list so the scenario stays the roster the + * optima below were verified on. Letting them in would not test anything new — + * it would ask about a player holding Heat Sink #8 without #7 — and every + * optimum would need brute-forcing again for no player anyone has. + */ const EXCLUDED_BUILDINGS = new Set([ "generator7", "cooler7", "flux_reactor", "doomStar_reactor", + // 1.1.10 + "cooler8", + "precursor_pyramid", + "ecto_compressor", ]); /** @@ -258,11 +271,13 @@ describe("the roster these layouts assume", () => { * expected layouts below need recomputing. */ - it("excludes exactly the four buildings the scenario names", () => { + it("excludes exactly the buildings the scenario names", () => { /* * Written so it holds whether or not the full roster currently unlocks these - * four — the scenario is "everything the player has, minus these", not "the - * default roster is exactly four bigger". + * — the scenario is "everything the player has, minus these", not "the + * default roster is exactly this many bigger". So a building the game adds + * lands IN the scenario until it is named above, and the next test is what + * notices: decide then whether it belongs to the mid game. */ const available = new Set(ROSTER.map((b) => b.id)); const full = new Set( diff --git a/packages/solver/tests/goldenLayoutsFullTier.test.ts b/packages/solver/tests/goldenLayoutsFullTier.test.ts new file mode 100644 index 0000000..ceb247a --- /dev/null +++ b/packages/solver/tests/goldenLayoutsFullTier.test.ts @@ -0,0 +1,340 @@ +/** + * Known-best-layout regression tests for a player holding EVERY building at its + * top tier. + * + * `goldenLayouts.test.ts` pins the same ladder of islands against a mid-game + * roster, where cooling is the binding constraint. At full tier it is not: one + * cooler8 carries the waste of fourteen fully loaded generator7s, while a single + * ecto_compressor overflows one generator7 twice over. So the question each + * island asks turns around — not "how much heat can I afford to cool" but "how + * many generators can my reactors fill" — and the answers below are a different + * ladder, not the old one scaled up. + * + * tiles shape layout power + * ----- ---------- ------------------------------ ----------- + * 3 row ecto + gen7 + cooler8 1.0600e23 + * 4 2x2 ecto + 2 gen7 + cooler8 2.1200e23 + * 5 3x3 - 4 ecto + 3 gen7 + cooler8 2.1499e23 + * 6 2x3 2 ecto + 3 gen7 + cooler8 3.1800e23 + * 7 3x3 - 2 2 ecto + 4 gen7 + cooler8 4.2400e23 + * 8 3x3 - 1 2 ecto + 5 gen7 + cooler8 4.2997e23 + * 9 3x3 3 ecto + 5 gen7 + cooler8 5.3000e23 + * + * Every one was confirmed by exhaustive brute force through `simulateIsland`, + * not by running the solver and writing down what it said: over {empty, gen7, + * cooler8, ecto, precursor_pyramid, doomStar} on every shape; over that set plus + * generator6, cooler7 and flux_reactor up to eight tiles; over the entire + * 42-building roster on the 3- and 4-tile boards; and each optimum against every + * 1- and 2-tile substitution from the entire roster. No lower tier appears in any + * of them. + * + * Unlike the mid-game ladder, the highest-scoring layout on every one of these + * islands is also stable — with cooling this plentiful there is no overheating + * trap to fall into — and the search finds the exact optimum every time, the + * full 3x3 included, so every case is asserted as an equality. + * + * THESE NUMBERS MUST NEVER GO DOWN. + */ +import { describe, expect, it } from "vitest"; +import { BUILDINGS, allUpgradesUnlocked } from "../src/data/buildings"; +import { getEffectiveBuildings } from "../src/data/effectiveBuildings"; +import { makeGrid } from "../src/grid"; +import { buildIslandContext, type IslandContext } from "../src/solver/context"; +import { + canCoolDirectProducer, + splitGridIntoIslands, +} from "../src/solver/island"; +import { + generatorEnergyRatio, + generatorWasteRatio, +} from "../src/solver/physics"; +import { internals, solveIsland } from "../src/solver/placementSearch"; +import { simulateIsland } from "../src/solver/simulate"; +import type { + EffectiveBuilding, + PlacedBuilding, + Placement, +} from "../src/solver/types"; +import { expectClose } from "./helpers"; + +const { offlineProducers } = internals; + +/** See `goldenLayouts.test.ts`: retries absorb an unlucky anneal, not a regression. */ +const ATTEMPTS = 3; +const TIME_BUDGET_S = 0.4; + +/** ASCII shorthand used by every pinned layout below. */ +const LEGEND: Record = { + C: "cooler8", + G: "generator7", + X: "ecto_compressor", +}; + +const ROSTER = getEffectiveBuildings(BUILDINGS, allUpgradesUnlocked()); +const BY_ID = new Map(ROSTER.map((b) => [b.id, b])); + +function building(id: string): EffectiveBuilding { + return BY_ID.get(id)!; +} + +const GEN = () => building("generator7"); +const COOLER = () => building("cooler8"); +const ECTO = () => building("ecto_compressor"); + +/** Power when `generators` generator7s share `reactors` ectos' heat. */ +function sharedOutput(reactors: number, generators: number): number { + const absorbed = Math.min( + reactors * ECTO().effectiveValue, + generators * GEN().effectiveValue, + ); + return absorbed * generatorEnergyRatio(GEN()); +} + +function layoutFrom(rows: string[]): { + ctx: IslandContext; + placement: Placement; +} { + const ctx = buildIslandContext( + makeGrid( + rows.map((row) => + [...row].map((char) => (char === "." ? "." : "G")).join(""), + ), + ), + ); + const placement: Placement = new Array(ctx.n).fill(null); + for (let i = 0; i < ctx.n; i++) { + const char = rows[ctx.ys[i]][ctx.xs[i]]; + if (char !== ".") placement[i] = building(LEGEND[char]); + } + return { ctx, placement }; +} + +function compositionOf( + items: { buildingId: string }[], +): Record { + const counts: Record = {}; + for (const item of items) { + counts[item.buildingId] = (counts[item.buildingId] ?? 0) + 1; + } + return counts; +} + +function scoreOf(rows: string[]): number { + const { ctx, placement } = layoutFrom(rows); + return simulateIsland(placement, ctx).totalPower; +} + +/** + * Both halves of a golden check: the pinned layout scores the optimum with + * nothing overheating, and the search reproduces it from a blank island. + */ +function golden( + layout: string[], + composition: Record, + optimum: () => number, +): void { + it("scores the known best layout", () => { + const { ctx, placement } = layoutFrom(layout); + const { totalPower, placements } = simulateIsland(placement, ctx); + + expect( + compositionOf( + placement + .filter((b): b is EffectiveBuilding => b !== null) + .map((b) => ({ buildingId: b.id })), + ), + ).toEqual(composition); + expectClose(totalPower, optimum()); + expect( + offlineProducers(placement, placements), + "the known-best layout must not contain an overheating building", + ).toEqual([]); + }); + + it("is found by the solver", async () => { + const islands = splitGridIntoIslands( + makeGrid(layout.map((row) => row.replace(/[^.]/g, "G"))), + canCoolDirectProducer(ROSTER), + ); + expect(islands.length, "test grid should be a single island").toBe(1); + + let best: PlacedBuilding[] = []; + let bestPower = -1; + for (let attempt = 0; attempt < ATTEMPTS; attempt++) { + const { placements, powerOutput } = await solveIsland( + islands[0], + ROSTER, + TIME_BUDGET_S, + ); + if (powerOutput > bestPower) { + best = placements; + bestPower = powerOutput; + } + if (bestPower >= optimum() * (1 - 1e-9)) break; + } + + expect(compositionOf(best)).toEqual(composition); + expectClose(bestPower, optimum()); + }, 20_000); +} + +describe("the roster these layouts assume", () => { + /* + * If one of THESE fails, the building catalogue changed — the solver is not + * at fault, and the expected layouts below need brute-forcing again. + */ + + it("tops out at the buildings the layouts name", () => { + const bestOf = (type: string) => + ROSTER.filter((b) => b.type === type).reduce((best, b) => + b.effectiveValue > best.effectiveValue ? b : best, + ); + + expect(bestOf("generator").id).toBe("generator7"); + expect(bestOf("cooler").id).toBe("cooler8"); + expect(bestOf("reactor").id).toBe("ecto_compressor"); + }); + + it("does the arithmetic that shapes every layout here", () => { + const fullWaste = GEN().effectiveValue * generatorWasteRatio(GEN()); + + // One cooler never runs out: it carries more generators than nine tiles hold. + expect(COOLER().effectiveValue / fullWaste).toBeGreaterThan(9); + + // One ecto overflows one generator, and fills two with a sliver to spare — + // which is the whole of what a third generator beside it can collect. + expect(ECTO().effectiveValue).toBeGreaterThan(2 * GEN().effectiveValue); + expect(ECTO().effectiveValue).toBeLessThan(3 * GEN().effectiveValue); + }); +}); + +describe("a three-tile row", () => { + /* + * G G G -> X G C + * + * The generator has to sit in the middle to touch both. It takes 1.42e23 of + * 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), + ); +}); + +describe("a four-tile square", () => { + /* + * G G -> G C + * G G X G + * + * 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), + ); + + it("doubles the three-tile row exactly", () => { + expectClose(scoreOf(["GC", "XG"]), 2 * scoreOf(["XGC"])); + }); +}); + +describe("a five-tile island", () => { + /* + * G G G -> G C G + * G G . G X . + * + * A second ecto would need a third generator to be worth anything, and five + * tiles cannot hold both. So the fifth tile is a third generator collecting + * the 4e21 that two generators leave on the table: the ecto's whole output, + * converted, is 2.1499e23 against 2.12e23. + */ + const LAYOUT = ["GCG", "GX."]; + golden( + LAYOUT, + { ecto_compressor: 1, generator7: 3, cooler8: 1 }, + () => sharedOutput(1, 3), + ); + + it("beats the four-tile square by only the ecto's overflow", () => { + expect(scoreOf(LAYOUT)).toBeGreaterThan(scoreOf(["GC", "XG"])); + expectClose( + scoreOf(LAYOUT) - scoreOf(["GC", "XG"]), + (ECTO().effectiveValue - 2 * GEN().effectiveValue) * + generatorEnergyRatio(GEN()), + ); + }); +}); + +describe("a six-tile island", () => { + /* + * G G G -> G C G + * G G G X X G + * + * Two ectos make 5.76e23 of heat, more than three generators accept + * (4.26e23), so all three run at full rating: 3.18e23. + */ + golden( + ["GCG", "XXG"], + { ecto_compressor: 2, generator7: 3, cooler8: 1 }, + () => sharedOutput(2, 3), + ); +}); + +describe("a seven-tile island", () => { + /* + * G G G -> G X G + * G G G X C G + * G . . G . . + * + * Two ectos (5.76e23) fill four generators (5.68e23) almost exactly. Every + * generator touches an ecto and the cooler. + */ + golden( + ["GXG", "XCG", "G.."], + { ecto_compressor: 2, generator7: 4, cooler8: 1 }, + () => sharedOutput(2, 4), + ); +}); + +describe("an eight-tile island", () => { + /* + * G G G -> G X G + * G G G X C G + * G G . G G . + * + * The five-tile story again: a third ecto would need generators there is no + * room for, so the eighth tile is a fifth generator catching the two ectos' + * 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), + ); + + it("prefers a fifth generator to a third reactor", () => { + expect(sharedOutput(2, 5)).toBeGreaterThan(sharedOutput(3, 4)); + }); +}); + +describe("a full three by three", () => { + /* + * G G G -> G X G + * G G G G C G + * G G G X X G + * + * Three ectos (8.64e23) fill five generators (7.10e23) with heat to spare; + * the centre cooler touches all five and carries their 1.8e23 of waste at a + * third of its capacity. 5.30e23. + */ + golden( + ["GXG", "GCG", "XXG"], + { ecto_compressor: 3, generator7: 5, cooler8: 1 }, + () => sharedOutput(3, 5), + ); +}); diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index 4d41b6e..1310118 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -502,6 +502,46 @@ describe("the theoretical max-power bound", () => { expectClose(boundFor(islandFor(["GGG"]), roster), 75); }); + describe("when one cooler can cover many producers", () => { + /* + * A cooler of 10000 against generators of 100 at a 1/4 waste ratio covers + * four hundred of them, so the cooling budget asks for one cooler and + * spends the other fifteen tiles of a 4x4 on 5 reactors and 10 generators: + * 1000 heat, 750 power. But cooling reaches a generator only from a cooler + * beside it, and one cooler has eight neighbours — two of the ten would + * never run. The real ceiling is the better of one cooler beside 8 + * generators (7 reactors, 800 heat) and two coolers beside 9 (5 reactors, + * 900 heat): 675. + */ + const roster = basicRoster({ + reactorValue: 200, + generatorValue: 100, + coolerValue: 10_000, + }); + const board = ["GGGG", "GGGG", "GGGG", "GGGG"]; + + it("keeps no more producers online than its coolers stand beside", () => { + expectClose(boundFor(islandFor(board, roster), roster), 675); + }); + + it("is still never beaten by a random layout", () => { + const island = islandFor(board, roster); + const bound = boundFor(island, roster); + const ctx = buildIslandContext(island.grid); + const rng = new Rng(20261011); + const options: (EffectiveBuilding | null)[] = [...roster, null]; + for (let attempt = 0; attempt < 2000; attempt++) { + const placement: Placement = Array.from({ length: ctx.n }, () => + rng.choice(options), + ); + expect( + simulateIsland(placement, ctx).totalPower, + `a layout beat the 'upper' bound on attempt ${attempt}`, + ).toBeLessThanOrEqual(bound + EPS); + } + }); + }); + it("is never beaten by a random layout", () => { /* * The bound property itself, exercised the only way that generalizes: throw diff --git a/packages/solver/tests/placementSearch.test.ts b/packages/solver/tests/placementSearch.test.ts index 37d0ff2..371b34e 100644 --- a/packages/solver/tests/placementSearch.test.ts +++ b/packages/solver/tests/placementSearch.test.ts @@ -314,30 +314,73 @@ describe("seed construction", () => { expect(keys).toEqual(sorted); }); - it("reaches a high fraction of the theoretical bound on a real map", async () => { - /* - * A quality floor on the real game roster and a real map. The seed normally - * lands within a few percent of the (loose) upper bound; this fails loudly - * if a change to the construction heuristic guts it. - */ - const roster = getEffectiveBuildings(BUILDINGS, allUpgradesUnlocked()); + /* + * A quality floor on a real map, so a change to the construction heuristic + * that guts it fails loudly. The replay is deterministic, so each floor sits + * a margin under a figure this exact seed reproduces. + */ + async function seedShareOfBound( + unlocks: Record, + ): Promise<{ power: number; bound: number }> { + const roster = getEffectiveBuildings(BUILDINGS, unlocks); const grid = (await decodeBlueprint(ISLAND_TEMPLATES[0].code)).grid; const island = splitGridIntoIslands(grid, canCoolDirectProducer(roster))[0]; - const { powerOutput } = await replayIslandDeterministic( island, roster, 1, 0, ); - const bound = estimateTotalMaxPower([island], roster); + return { + power: powerOutput, + bound: estimateTotalMaxPower([island], roster), + }; + } + + it("reaches a high fraction of the theoretical bound on a real map", async () => { + /* + * On the roster this floor was calibrated against: everything unlocked as + * of the game before 1.1.10, which added two tiers to Heat Sink #7, three to + * Generator #7 and three late-game buildings. Pinned, because what it + * guards is the heuristic, and a roster that changes shape moves the figure + * without the heuristic doing anything. Measured 83.0%. + */ + const all = allUpgradesUnlocked(); + const before: Record = { + ...all, + cooler7: all.cooler7 - 2, + generator7: all.generator7 - 3, + }; + for (const id of ["cooler8", "precursor_pyramid", "ecto_compressor"]) + delete before[id]; + const { power, bound } = await seedShareOfBound(before); expect( - powerOutput, - `seed power ${powerOutput.toExponential(3)} is far below the ` + + power, + `seed power ${power.toExponential(3)} is far below the ` + `${bound.toExponential(3)} bound`, ).toBeGreaterThan(0.75 * bound); }, 20_000); + + it("holds its floor on the current roster too", async () => { + /* + * The game as it ships. Since 1.1.10 one Ecto Compressor outputs about two + * top generators' worth of heat, where a top generator used to take about + * four of the best reactors, so the best hub is a reactor between + * generators rather than a generator ringed by reactors. The seed is built + * around the old shape and lands lower: measured 66.1%, against 83.0% on the + * roster before. This floor is the new figure less a margin, so a drop from + * here still fails, and lifting the seed for the new shape is the fix that + * would let it rise. + */ + const { power, bound } = await seedShareOfBound(allUpgradesUnlocked()); + + expect( + power, + `seed power ${power.toExponential(3)} is far below the ` + + `${bound.toExponential(3)} bound`, + ).toBeGreaterThan(0.6 * bound); + }, 20_000); }); describe("pruning dead weight", () => { diff --git a/public/data/web_atlas.json b/public/data/web_atlas.json index bc9379e..e5206d2 100644 --- a/public/data/web_atlas.json +++ b/public/data/web_atlas.json @@ -1615,10 +1615,105 @@ ] ] }, - "cooler6": { + "ecto_compressor": { "frame": { "x": 1254, "y": 516, + "w": 205, + "h": 192 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 205, + "h": 192 + }, + "sourceSize": { + "w": 205, + "h": 192 + }, + "anchor": { + "x": 0.5, + "y": 0.5 + }, + "pixelsToUnits": 100.0, + "hitAreas": [ + [ + 95.5, + 26.0, + 89.5, + 18.0, + 76.5, + 13.0, + 75.5, + -4.0, + 62.5, + -23.0, + 63.5, + -31.0, + 54.5, + -57.0, + 49.5, + -60.0, + 37.5, + -58.0, + 37.5, + -70.0, + 31.5, + -83.0, + 25.5, + -91.0, + 10.5, + -96.0, + -8.5, + -96.0, + -21.5, + -93.0, + -29.5, + -85.0, + -38.5, + -67.0, + -52.5, + -66.0, + -54.5, + -61.0, + -54.5, + -45.0, + -60.5, + -39.0, + -71.5, + -21.0, + -73.5, + -5.0, + -68.5, + 10.0, + -88.5, + 21.0, + -93.5, + 27.0, + -93.5, + 40.0, + -88.5, + 47.0, + -16.5, + 88.0, + 17.5, + 88.0, + 35.5, + 78.0, + 90.5, + 46.0, + 95.5, + 40.0 + ] + ] + }, + "cooler6": { + "frame": { + "x": 1461, + "y": 516, "w": 200, "h": 188 }, @@ -1696,7 +1791,7 @@ }, "generator5": { "frame": { - "x": 1456, + "x": 1663, "y": 516, "w": 188, "h": 187 @@ -1795,7 +1890,7 @@ }, "generator6": { "frame": { - "x": 1646, + "x": 1853, "y": 516, "w": 186, "h": 187 @@ -2501,10 +2596,103 @@ ] ] }, - "zero_point_reactor": { + "precursor_pyramid": { "frame": { "x": 996, "y": 732, + "w": 207, + "h": 177 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 207, + "h": 177 + }, + "sourceSize": { + "w": 207, + "h": 177 + }, + "anchor": { + "x": 0.5, + "y": 0.5 + }, + "pixelsToUnits": 100.0, + "hitAreas": [ + [ + 93.5, + 8.5, + 81.5, + -5.5, + 71.5, + -11.5, + 35.5, + -47.5, + 28.5, + -56.5, + 26.5, + -65.5, + 8.5, + -86.5, + 2.5, + -88.5, + -4.5, + -88.5, + -15.5, + -79.5, + -29.5, + -62.5, + -26.5, + -53.5, + -41.5, + -38.5, + -43.5, + -44.5, + -59.5, + -41.5, + -69.5, + -14.5, + -86.5, + -1.5, + -94.5, + 13.5, + -94.5, + 24.5, + -93.5, + 28.5, + -88.5, + 33.5, + -82.5, + 44.5, + -74.5, + 51.5, + -59.5, + 62.5, + -43.5, + 66.5, + -29.5, + 68.5, + -17.5, + 74.5, + -4.5, + 80.5, + 6.5, + 80.5, + 21.5, + 73.5, + 94.5, + 30.5, + 94.5, + 11.5 + ] + ] + }, + "zero_point_reactor": { + "frame": { + "x": 1205, + "y": 732, "w": 184, "h": 176 }, @@ -2610,7 +2798,7 @@ }, "antimatter_plant": { "frame": { - "x": 1182, + "x": 1391, "y": 732, "w": 204, "h": 172 @@ -2933,7 +3121,7 @@ }, "hyper_space_core": { "frame": { - "x": 1388, + "x": 1597, "y": 732, "w": 222, "h": 171 @@ -3030,7 +3218,7 @@ }, "flux_reactor": { "frame": { - "x": 1612, + "x": 1821, "y": 732, "w": 216, "h": 169 @@ -3115,8 +3303,8 @@ }, "cooler5": { "frame": { - "x": 1830, - "y": 732, + "x": 2, + "y": 920, "w": 200, "h": 168 }, @@ -3186,7 +3374,7 @@ }, "cooler4": { "frame": { - "x": 2, + "x": 204, "y": 920, "w": 184, "h": 167 @@ -3257,7 +3445,7 @@ }, "gas_plant": { "frame": { - "x": 188, + "x": 390, "y": 920, "w": 198, "h": 163 @@ -3356,7 +3544,7 @@ }, "solar_panel": { "frame": { - "x": 388, + "x": 590, "y": 920, "w": 204, "h": 160 @@ -3447,7 +3635,7 @@ }, "quantum_reactor": { "frame": { - "x": 594, + "x": 796, "y": 920, "w": 194, "h": 159 @@ -3540,7 +3728,7 @@ }, "trees1": { "frame": { - "x": 790, + "x": 992, "y": 920, "w": 232, "h": 159 @@ -3685,7 +3873,7 @@ }, "generator4": { "frame": { - "x": 1024, + "x": 1226, "y": 920, "w": 172, "h": 154 @@ -3768,7 +3956,7 @@ }, "generator3": { "frame": { - "x": 1198, + "x": 1400, "y": 920, "w": 158, "h": 150 @@ -3847,7 +4035,7 @@ }, "black_hole_reactor": { "frame": { - "x": 1358, + "x": 1560, "y": 920, "w": 192, "h": 147 @@ -4170,7 +4358,7 @@ }, "cooler7": { "frame": { - "x": 1552, + "x": 1754, "y": 920, "w": 181, "h": 143 @@ -4243,10 +4431,85 @@ ] ] }, + "cooler8": { + "frame": { + "x": 2, + "y": 1090, + "w": 181, + "h": 143 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 181, + "h": 143 + }, + "sourceSize": { + "w": 181, + "h": 143 + }, + "anchor": { + "x": 0.5, + "y": 0.5 + }, + "pixelsToUnits": 100.0, + "hitAreas": [ + [ + 76.5, + -9.5, + 71.5, + -14.5, + 64.5, + -19.5, + 55.5, + -18.5, + 51.5, + -39.5, + 29.5, + -55.5, + -1.5, + -71.5, + -10.5, + -71.5, + -47.5, + -50.5, + -55.5, + -40.5, + -60.5, + -6.5, + -70.5, + -0.5, + -79.5, + 9.5, + -79.5, + 32.5, + -67.5, + 42.5, + -43.5, + 44.5, + -37.5, + 57.5, + -25.5, + 65.5, + -5.5, + 65.5, + -0.5, + 64.5, + 9.5, + 60.5, + 65.5, + 30.5, + 76.5, + 17.5 + ] + ] + }, "trees2": { "frame": { - "x": 1735, - "y": 920, + "x": 185, + "y": 1090, "w": 211, "h": 143 }, @@ -4360,8 +4623,8 @@ }, "cooler3": { "frame": { - "x": 2, - "y": 1089, + "x": 398, + "y": 1090, "w": 184, "h": 142 }, @@ -4431,8 +4694,8 @@ }, "pond": { "frame": { - "x": 188, - "y": 1089, + "x": 584, + "y": 1090, "w": 228, "h": 140 }, @@ -4534,8 +4797,8 @@ }, "rocks": { "frame": { - "x": 418, - "y": 1089, + "x": 814, + "y": 1090, "w": 226, "h": 134 }, @@ -4619,8 +4882,8 @@ }, "generator": { "frame": { - "x": 646, - "y": 1089, + "x": 1042, + "y": 1090, "w": 132, "h": 129 }, @@ -4684,8 +4947,8 @@ }, "generator2": { "frame": { - "x": 780, - "y": 1089, + "x": 1176, + "y": 1090, "w": 135, "h": 128 }, @@ -4759,8 +5022,8 @@ }, "generator7": { "frame": { - "x": 917, - "y": 1089, + "x": 1313, + "y": 1090, "w": 131, "h": 128 }, @@ -4828,8 +5091,8 @@ }, "cooler2": { "frame": { - "x": 1050, - "y": 1089, + "x": 1446, + "y": 1090, "w": 184, "h": 124 }, @@ -4897,8 +5160,8 @@ }, "transformer": { "frame": { - "x": 1236, - "y": 1089, + "x": 1632, + "y": 1090, "w": 127, "h": 124 }, @@ -4966,8 +5229,8 @@ }, "cooler1": { "frame": { - "x": 1365, - "y": 1089, + "x": 1761, + "y": 1090, "w": 184, "h": 116 }, @@ -5035,8 +5298,8 @@ }, "tile_water_left": { "frame": { - "x": 1551, - "y": 1089, + "x": 2, + "y": 1235, "w": 120, "h": 104 }, @@ -5061,8 +5324,8 @@ }, "tile_water_right": { "frame": { - "x": 1673, - "y": 1089, + "x": 124, + "y": 1235, "w": 120, "h": 104 }, @@ -5087,8 +5350,8 @@ }, "tile_water_middle": { "frame": { - "x": 1795, - "y": 1089, + "x": 246, + "y": 1235, "w": 226, "h": 102 }, @@ -5113,8 +5376,8 @@ }, "indicator_grid": { "frame": { - "x": 2, - "y": 1233, + "x": 474, + "y": 1235, "w": 112, "h": 66 }, @@ -5216,8 +5479,8 @@ }, "indicator_normal": { "frame": { - "x": 116, - "y": 1233, + "x": 588, + "y": 1235, "w": 84, "h": 51 }, @@ -5242,8 +5505,8 @@ }, "indicator_idle": { "frame": { - "x": 202, - "y": 1233, + "x": 674, + "y": 1235, "w": 84, "h": 51 }, @@ -5268,8 +5531,8 @@ }, "indicator_overheat": { "frame": { - "x": 288, - "y": 1233, + "x": 760, + "y": 1235, "w": 84, "h": 51 }, @@ -5300,7 +5563,7 @@ "format": "RGBA8888", "size": { "w": 2048, - "h": 1301 + "h": 1341 }, "scale": 1 } diff --git a/public/data/web_atlas.webp b/public/data/web_atlas.webp index 247c19d..945c741 100644 Binary files a/public/data/web_atlas.webp and b/public/data/web_atlas.webp differ diff --git a/public/icons/cooler8.webp b/public/icons/cooler8.webp new file mode 100644 index 0000000..808e7b9 Binary files /dev/null and b/public/icons/cooler8.webp differ diff --git a/public/icons/ecto_compressor.webp b/public/icons/ecto_compressor.webp new file mode 100644 index 0000000..bd722c9 Binary files /dev/null and b/public/icons/ecto_compressor.webp differ diff --git a/public/icons/precursor_pyramid.webp b/public/icons/precursor_pyramid.webp new file mode 100644 index 0000000..b539894 Binary files /dev/null and b/public/icons/precursor_pyramid.webp differ diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 1948b2e..5df9eaa 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -207,7 +207,7 @@ roster — which is to say, in normal play but never in a quick smoke test: - **`.scroll-body` needs `min-height: 0`.** A flex item's automatic minimum size is its _content_ size, so `flex: 1` alone cannot shrink a list below the height of - every card in it. Without it a full 38-building roster refuses to shrink and + every card in it. Without it a full 41-building roster refuses to shrink and overflows the sheet. `.sidebar-panel` clips its own overflow, which is why only the phone ever showed it. - **`.app-shell` uses `overflow: clip`, not `overflow: hidden`.** The mobile sheet