From 7675b1f964fa66c270ef8f41efbc7f5ba95b65f8 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:04:56 +0300 Subject: [PATCH 01/21] Model anomalies and Time Lab research; thread them to the solver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game's prestige update added two things that change what a building is worth: a timeline-wide anomaly, and three Time Lab researches that scale a role's stats. This brings both across as far as the search, which does not yet act on the anomaly. Time Lab research folds into the roster rather than into the engine. `prestigeScales()` resolves levels to one factor per role and `getEffectiveBuildings` applies it, so an `EffectiveBuilding` crosses the worker boundary with the research already in its numbers. `EffectiveBuilding` gains `baseValue` — the authored tier value, passed through every scaling helper untouched — because a placement's tier is resolved back out of the reported figure, and reporting a scaled one resolves to a higher tier and scales twice. `ANOMALIES` is a tagged union over the rule shape rather than the anomaly's name, so a re-parameterised anomaly is a table entry and a new rule shape is a compile error. `"none"` is an entry rather than an absence, and `getAnomaly` is total: the id arrives off localStorage and the worker boundary. Blueprint codes grow a third section carrying the rules a board was built under — the anomaly byte, then one pair per research. Tiers said what the buildings were, and that stopped being the whole story once research changed what a tier is worth. It decodes to `null` when a code does not say, which is not the same as "no anomaly, no research". Setup gains a Time Lab tab holding both, and three surfaces go purple while an anomaly is selected. The board's own reds and ambers are deliberately left alone. The solver accepts an anomaly and does not act on one: it is threaded through `SolveOptions`, the worker request and `IslandContext`, which is where the stages will read it. --- CLAUDE.md | 293 +++++++++++++++- docs/SOLVER.md | 68 +++- docs/game-logic.md | 147 ++++++++ .../solver/fixtures/full_upgrades_seed1.json | 2 +- .../solver/fixtures/no_buildable_tiles.json | 2 +- packages/solver/fixtures/single_tile.json | 2 +- .../solver/fixtures/small_grid_seed42.json | 2 +- .../solver/fixtures/three_island_uneven.json | 2 +- .../solver/fixtures/two_island_seed7.json | 2 +- packages/solver/src/data/anomalies.ts | 99 ++++++ .../solver/src/data/effectiveBuildings.ts | 105 +++++- packages/solver/src/data/prestige.ts | 136 ++++++++ packages/solver/src/encoding/blueprint.ts | 269 +++++++++++++-- packages/solver/src/index.ts | 2 + packages/solver/src/solver/context.ts | 41 ++- packages/solver/src/solver/island.ts | 36 +- packages/solver/src/solver/placementSearch.ts | 8 +- packages/solver/src/solver/simulate.ts | 4 +- packages/solver/src/solver/solver.ts | 29 +- packages/solver/src/solver/types.ts | 204 +++++++++++ packages/solver/tests/alternates.test.ts | 9 +- packages/solver/tests/anomalies.test.ts | 104 ++++++ packages/solver/tests/blueprint.test.ts | 187 +++++++++- packages/solver/tests/helpers.ts | 20 +- packages/solver/tests/island.test.ts | 13 +- .../tests/placementSearch.downgrade.test.ts | 22 +- packages/solver/tests/placementSearch.test.ts | 6 +- packages/solver/tests/prestige.test.ts | 247 ++++++++++++++ packages/solver/tests/rosterPower.test.ts | 4 + packages/solver/tests/simulate.test.ts | 2 + public/icons/anomaly_cryo_nexus.webp | Bin 0 -> 16392 bytes .../icons/anomaly_singularity_isolation.webp | Bin 0 -> 14226 bytes public/icons/anomaly_tidal_ascendancy.webp | Bin 0 -> 13728 bytes public/icons/prestige_absolute_zero.webp | Bin 0 -> 21058 bytes public/icons/prestige_infinite_grid.webp | Bin 0 -> 27342 bytes public/icons/prestige_stellar_forge.webp | Bin 0 -> 27718 bytes src/App.svelte | 43 ++- src/app.css | 131 +++++++ src/lib/components/header/GridControls.svelte | 9 +- src/lib/components/hud/BoardActions.svelte | 25 +- .../inspector/BoardStatsCard.svelte | 38 ++- .../components/sidebar/AnomalySelector.svelte | 320 ++++++++++++++++++ .../components/sidebar/ConfigSidebar.svelte | 133 +++++++- .../sidebar/PrestigeUpgrades.svelte | 316 +++++++++++++++++ src/lib/simulation/simulator.test.ts | 1 + src/lib/simulation/simulator.ts | 5 +- src/lib/state/boardHistory.test.ts | 4 +- src/lib/state/config.svelte.ts | 106 +++++- src/lib/state/index.ts | 6 +- src/lib/state/layout.svelte.ts | 81 ++++- src/lib/state/solver.svelte.ts | 47 ++- src/lib/state/ui.svelte.ts | 11 +- src/lib/storage/storage.ts | 39 ++- src/lib/worker/islandWorker.ts | 3 +- src/lib/worker/solverCoordinator.ts | 6 + src/lib/worker/variants.test.ts | 4 + src/lib/worker/workerClient.ts | 23 ++ 57 files changed, 3285 insertions(+), 133 deletions(-) create mode 100644 packages/solver/src/data/anomalies.ts create mode 100644 packages/solver/src/data/prestige.ts create mode 100644 packages/solver/tests/anomalies.test.ts create mode 100644 packages/solver/tests/prestige.test.ts create mode 100644 public/icons/anomaly_cryo_nexus.webp create mode 100644 public/icons/anomaly_singularity_isolation.webp create mode 100644 public/icons/anomaly_tidal_ascendancy.webp create mode 100644 public/icons/prestige_absolute_zero.webp create mode 100644 public/icons/prestige_infinite_grid.webp create mode 100644 public/icons/prestige_stellar_forge.webp create mode 100644 src/lib/components/sidebar/AnomalySelector.svelte create mode 100644 src/lib/components/sidebar/PrestigeUpgrades.svelte diff --git a/CLAUDE.md b/CLAUDE.md index c2feb78..31fd321 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,16 +106,162 @@ the building actually ran at — the two diverge the moment an upgrade is bought behind a standing building. **The catalogue is generated, so don't hand-edit it.** An external extraction -script in `tools/` (gitignored — it reads the game's own files and does not -ship) emits the `BUILDING_TABLE` block in `packages/solver/src/data/buildings.ts` -from the extracted roster. It splices only that declaration; the prose and the -helpers around it are hand-owned and survive regeneration. +script — it reads the game's own files and is not part of this repo — emits the +`BUILDING_TABLE` block in `packages/solver/src/data/buildings.ts` from the +extracted roster. It splices only that declaration; the prose and the helpers +around it are hand-owned and survive regeneration. The tables carry the game's **authored ScriptableObject doubles**, not the 3-significant-figure numbers its UI shows and not the extractor's rounded JSON (which loses 22 of the roster's 301 numbers): cooler2 tier 3 is 18662, not 1.86e4. +## The Time Lab: research and anomalies + +Prestiging ("Time Jump") does two things to the numbers, and Setup's third tab — +named **Time Lab** after the game's own screen — holds both. + +### Time Lab research + +`PRESTIGE_UPGRADES` (`data/prestige.ts`) carries **three** of the game's ten +researches: Absolute Zero (cooler cooling), Infinite Grid (generator and wind +turbine stats) and Stellar Forge (reactor heat). The other seven move research +income, research time, chronons, obstacle-removal and building prices — all +decided *before* the solver is handed a board, so none can change which layout +is best. The file names them so it is clear they were read and dismissed. + +Three things bind: + +- **Each is a uniform multiplier on the roles it names**, the same shape the + stat anomalies turned out to have, so both go through + `scaleEffectiveBuilding`. Infinite Grid raises the overheat threshold as well + as the energy, which is what keeps it a bigger bet rather than free power. +- **It folds into the roster, not into the solver.** `prestigeScales()` resolves + levels to one factor per role and `getEffectiveBuildings` applies it, so by the + time an `EffectiveBuilding` crosses the worker boundary the research is already + in its three numbers and nothing in the engine knows a Time Lab exists — which + is why the worker protocol has no field for it. + + **That means the scales have to reach `planSolve`, and a run does not resolve + its own roster.** `solveProgressive` hands over `buildings` and + `unlockedUpgrades` and the coordinator resolves from those, so research + applied anywhere else — the readout, the run estimate — reaches the search + only if `SolveRunOptions.prestige` carries it there. Omit it and the run comes + back with a perfectly valid layout optimised for a roster the player does not + have, which nothing on screen would contradict. `prestige.test.ts` pins the + plan's roster and its upper bound against exactly that. + `effectiveAtValue` takes it too, and must: a placement stores the **authored** + tier value, which identifies the tier and is deliberately never rewritten, so + the research is applied on the way out every time. +- **A multiplier breaks what `effectiveValue` used to mean, so + `EffectiveBuilding` carries `baseValue` beside it** — the authored tier value, + which every scaling helper passes through **untouched**. `simulateIsland` + reports that, never `effectiveValue`, because a placement's tier is resolved + back out of the reported number by matching the catalogue: report a scaled one + and it matches a *higher* tier and gets scaled a second time. A generator at + authored 320 under a x2 research reported 640, resolved as the authored 640 + tier, and came back 1280 at one level too high — in the readout's ceilings, + the `Lv.` chip, `copySolveToBoard` and the tier table of every share code, with + nothing failing anywhere. +- **It follows `buildingUpgrades`' convention exactly** — presence of the key is + what "researched" means, the value is a 0-based level index the UI numbers from + 1, and un-researching deletes the key. Same control, same gesture, so a second + convention would only be a way to rate a board at the wrong level without + failing. `PrestigeUpgrades` is `BuildingUnlockCard`'s idiom down to the edge + stripe and the tier row disabling when off. +- **The table authors the game's `BonusPercentage`, not the multiplier.** The + game works and reads in bonuses — its UI says "+100%", never "×2" — so + `bonuses` is what is carried and `prestigeMultiplier` (`1 + bonus`) is the one + place the factor comes from. Carrying both would be two spellings of one number + to keep in step; the card prints the bonus and puts the factor in the tier + button's tooltip. + +`layoutState` cannot read `configState`, so it **holds** the scales +(`setPrestige`) rather than taking them per call — `recalculate()` runs from a +dozen internal places that cannot all grow an argument. `hydrateState` seeds it +before the board exists; `App.svelte`'s effect pushes changes. A previewed board +sits it out, the same as `rebasePlacements`: it is the author's board at the +author's research, and a blueprint does not record what that was. + +**Stellar Forge is the one uncertain call.** The game's `heat_producer` category +holds reactors *and* direct producers, so "all Heat Producers" read literally +would take the wind turbine — but Infinite Grid goes out of its way to name wind +turbines, which is only worth saying if this one does not cover them. Modelled as +reactors only; the cost of being wrong is a turbine that per `docs/game-logic.md` +almost never earns a tile. + +### Anomalies + +Prestiging also picks an **anomaly** that changes the rules for the whole next +timeline. `docs/game-logic.md` is the authority on what each one does and +`docs/SOLVER.md` on how the table is shaped; the short version: + +- `AnomalyDefinition` (`solver/types.ts`) is a tagged union over the **rule + shape**, not the anomaly's name, and `ANOMALIES` (`data/anomalies.ts`) is + **hand-owned** — unlike `BUILDING_TABLE` beside it, what the extractor can + read is the text and the multipliers, never the rule shape those numbers plug + into. +- `"none"` is an entry, not an absence, and `getAnomaly` is total: an id it does + not know resolves to the baseline, because the id arrives off `localStorage` + and the worker boundary. +- Every stat anomaly is a **uniform** scale on a building's three figures + (`scaleEffectiveBuilding`), so a bonus is never free power — the cooling it + needs grows with it. +- `configState.anomalyId` is the live choice, persisted under its own key rather + than in `ui_prefs` because it is a **solve input** like the roster, not a + preference about the app — which is also why `solveSignature()` counts it and + a solve found under another anomaly restores as stale. +- **Cryo Nexus pools across the whole board.** The game's "island" is the map, + so under it the islands `splitGridIntoIslands` produces **do** interact — + against the assumption the worker pool, the budget split, `IslandBest` and + `variants.ts` are built on. Heat stays adjacency-bound, so an island is still + solvable alone; it is just no longer described by its best power but by a + frontier of power against net cooling contributed, with the board combining + those under one scalar budget. See `docs/SOLVER.md`. +- **`ANOMALIES` is transcribed by hand from the same extractor's output**, which + emits an anomaly record alongside the roster and drops the icons into + `public/icons/anomaly_.webp`. Unlike `BUILDING_TABLE`, **nothing splices + this table** — the extractor writes its record and stops — so a new anomaly is + brought across by hand. The game's `{0}`-templated strings land here with their + `values` resolved, so the wording and the numbers the solver runs on cannot + disagree. +- **`sidebar/AnomalySelector` is the only place it is chosen**, under the + research on Setup's Time Lab tab. It mirrors a choice already made in the game rather than making one, so + it is built to be recognised rather than shopped: icon and name first, the + game's benefit/drawback pair beside them, and the full rule only under the + selected card — four rules at once is a wall of text on a phone describing + three timelines nobody is in. +- **Changing it re-scores both boards** through the same `$effect` in + `App.svelte` a roster change goes through, and for the same reason. It is + tracked separately there because the two ask for different work: a bought tier + changes which tier a *placement* resolves to (`rebasePlacements`), while an + anomaly changes none of them — the same building at the same tier is simply + rated differently — so it needs the re-score alone. +- **The solver accepts an anomaly and does not yet act on one.** It is threaded + the whole way — `SolveOptions.anomalyId` / `SolveRunOptions.anomalyId`, the + worker request, and `IslandContext.anomaly`, which is where the stages will + read it because every stage already holds a context. The model, the board + window the rules need, the catalogue and the UI are in; the rules are not, and + nothing on screen says a solve ignored one. + + The anomaly crosses the worker boundary **as an id**, resolved again on the + far side, so the message stays a string rather than a table entry that has to + survive structured cloning. `replayIslandDeterministic` passes none and never + will: the fixtures are a determinism harness for the search, and a rule change + is a different question asked of it. + +**Three Time Lab upgrades scale building stats too**, and they are not anomalies: +they are bought with Chronons, survive a Time Jump, and stack with whatever +anomaly is running. The extractor's record covers all ten Time Lab upgrades; the +three a layout can see are **Stellar Forge** (every heat +producer), **Infinite Grid** (every generator), and **Absolute Zero** (every +cooler), each five levels of `BonusPercentage` 1.0 to 4.0. That field is a +fraction rather than a percent -- the same field is 0.05 on Chronon Reactor, +which the game shows as +5% -- so the levels are worth x2 to x5. Like an +anomaly's, the scale is uniform, so the cooling a boosted producer needs grows +with it. Their badges ship as `public/icons/prestige_.webp`. Nothing models +them yet. + ## Architecture ### Folder map @@ -167,8 +313,8 @@ cascade. ### Blueprint is the format `encoding/blueprint.ts` in the solver package is deflate + base64url, one byte -per tile, carrying terrain **and** buildings in a single payload with the grid's -dimensions in the first two bytes. The shipped island templates (`data/maps.ts`), +per tile, carrying terrain **and** buildings in a single payload behind a +version byte and the grid's dimensions. The shipped island templates (`data/maps.ts`), the user's saved edits (`localStorage`), the Share button and the CLI's output all use it. It needs `CompressionStream`, so encode and decode are **async** — which is why loading a @@ -177,6 +323,17 @@ grid is an awaited step rather than something a constructor can do. - **Never compare encoded codes to test whether two layouts match.** DEFLATE is only required to round-trip; two engines may emit different bytes for the same input. Use `blueprintKey()`, which compares the uncompressed payload. +- **A code says which format it is, and an old one is recognised by its + width.** `BLUEPRINT_VERSION` is byte 0 of every new code; codes written before + it began with the width instead, so the two are told apart by *value* — a + board is at least `MIN_GRID_DIM` (5) on a side, so a first byte below that + cannot be a width. Which is why that constant now lives in the codec rather + than in the size stepper that enforces it, and why lowering it would not + shrink a board but would make some old codes unreadable. It constrains old + codes only: a versioned code states its width where no value is ambiguous, so + the 4x4 fixtures encode fine. An unknown version is **refused**, never + guessed at — the tiles are positional, so misreading one produces a different + board rather than an error. - **`IslandTemplate` has no `width`/`height`.** The code carries them, so there is nothing to drift out of sync with the terrain. To author a new island, build it in the app and press Share. @@ -192,6 +349,25 @@ ends after the tiles, and a reader that predates it stops there too. An **empty* table means "tiers unknown", never "everything at tier 0", and the caller resolves them from its own unlocks. +**A third section carries the rules the board was built under** — the anomaly +byte, then a count and one `[research byte][level index]` pair per Time Lab +upgrade. Tiers say what the buildings were, and that stopped being the whole +story once research changed what a tier is worth: a board shared out of a +×5-cooling timeline is not the board a reader without that research would get. + +Two things about it: + +- **Rules are read only after the tier table, so a code carrying them carries a + tier count byte first, even a zero one.** A byte for a strictly sequential + parse, and zero there is not a contradiction — an empty table already meant + "tiers unknown". +- **`rules` decodes to `null` when the code does not say**, which is not the + same as a code that states "no anomaly, no research". A reader that confused + the two would rate someone else's board at nothing and still print a figure. + `loadPreview` uses the author's research where the code names it and **no** + research where it does not — never the reader's own, for the same reason it + uses the author's tiers. + Two rules follow, and they are not symmetric: - **`encodeBlueprint` takes tiers; `blueprintKey` never does.** The key answers @@ -199,7 +375,7 @@ Two rules follow, and they are not symmetric: template, the solver's board signature — mean the arrangement, not what the roster currently rates it at. Fold tiers in and buying an upgrade reads as a repainted board. -- **Share codes carry tiers, saved layouts do not.** A save is the player's own +- **Share codes carry tiers and rules, saved layouts carry neither.** A save is the player's own board and is meant to pick up upgrades bought since (`#applySaved` on load, `rebasePlacements` live). Freezing tiers into the save would put those two in permanent disagreement, so `persist()` calls `encodeBlueprint` directly while @@ -1357,20 +1533,27 @@ descendants still bubble through an element with `pointer-events: none` — that property only stops the element being a hit target itself, which is what keeps canvas drags working through the HUD's empty margins. -### Setup is two tasks, and it shows one at a time +### Setup is three tasks, and it shows one at a time -The panel holds two unrelated jobs — **which island** and **which buildings**. -Stacked in one scroller with the islands on top, the roster is never on screen -when Setup opens on a 390x844 phone, and the Reactors tab behind it is 24 -cards. +The panel holds three unrelated jobs — **which island**, **which buildings** and +**the Time Lab** (research, then anomaly). Stacked in one scroller with the islands on top, the roster is +never on screen when Setup opens on a 390x844 phone, and the Reactors tab behind +it is 24 cards. + +The Time Lab is a tab rather than a row somewhere because it is a third input of +the same kind, not a qualifier on either of the other two: it changes what a run +comes back with, it is set once and then tried against island after island, and +it needs room — four cards, each with the game's own icon and wording. Three +labels do fit a 374px phone, but only because each may ellipsize (`min-width: 0` +on the buttons). `uiState.setupTab` picks between them and `ConfigSidebar` renders the switch and the body as **snippets**, used by both shells: a sheet and a docked column differ in their chrome and their gesture, never in this. Four things about it: -- **It defaults to `islands`, and is not persisted.** The roster is set once; the - same roster is then tried against one island after another, so the island list - is the recurring task. This is view state, not a preference. +- **It defaults to `islands`, and is not persisted.** The roster and the Time Lab + are set once; both are then tried against one island after another, so the + island list is the recurring task. This is view state, not a preference. - **The switch sits between the fixed top region and the scroller.** Inside `.sheet-top` it would inflate the measured `peekHeight`; inside `.scroll-body` it would scroll away, and the one control that says where you are is the last @@ -1535,6 +1718,7 @@ drifts to twelve-odd hues — a Share button wearing the colour the board uses f | `--status-idle` | the board only: this building is doing nothing. | | `--danger` | the board: overheating. In the UI: this destroys something. | | `--warn` | a limit is reached, or this board is not yours to edit. | +| `--anomaly-*` | Setup's Anomaly tab only, and nowhere else. | **The two board readings are reserved, and that is the whole point.** The pad under every building, the pulse that breathes it and the readout in the corner all speak @@ -1554,6 +1738,85 @@ One hue sits outside the law and says so in the file: `--heart`, for the donate button, because a donate heart is pink everywhere on the web and `--danger` would tell the user the button breaks something. +**The anomaly cards' green/red pair is the second exception, and it is a +narrower one.** `--benefit-*` and `--drawback-*` are sampled from the game's own +"Choose an anomaly" screen — both stripe fills and the text on each — because +that is the one screen in the app mirroring a screen in the game: the player has +just chosen there and is confirming here, so the pairing they read a moment ago +is worth more than a palette of our own. They are **not** `--status-ok` and +`--danger`, which stay reserved to the board, and reusing those would not even +have looked right: the board speaks as a saturated accent on a dark ground, these +are muted fills carrying near-white text. A player meets them as panels, not as +status lights, which is what keeps the reservation honest. + +**`--anomaly-selected` is the sharper half of that exception**, because `--neon` +means selected everywhere else and on this list it does not: the chosen card is +ringed in the game's own green. Two selection colours is a real cost, and it is +taken for the same reason and stretches no further — showing a player their own +choice in a colour they will not recognise from the screen they made it on is the +larger one. Nothing outside `AnomalySelector` may take it. + +**Three surfaces go purple while an anomaly is selected** — Setup on *every* +tab, the readout, and Run in the HUD — all reading `configState.hasAnomaly`, so +it is one signal rather than three effects. Keyed on the selection and not on +the Anomaly tab, because that is what it says: this timeline is not running the +ordinary rules, and the island list, the roster and the board's figures are all +read under them. Nothing selected puts every one of them back to navy, which is +the common case. Each surface keeps **its own alpha** — sheet 0.97, docked panel +0.88, readout 0.94 — which is why `--anomaly-panel-rgb` is a bare triplet rather +than a colour: one decision, not three tokens. + +The type and chrome follow by **re-pointing the inherited tokens** on each container — `ConfigSidebar` +and `BoardStatsCard` re-point `--text`, `--text-muted`, `--text-dim` and +`--border` at the `--anomaly-*` values, plus `--border-neon`, `--neon-faint` and +`--surface-panel-solid` on Setup, so every hairline and the roster's sticky bar +go along too — and no component learns that anomalies exist. + +Setup re-points the **whole `--neon` family** on top of that, so every mark +inside it that means "selected" goes purple with the ground: the SETUP heading, +the active tab's label and underline, the chosen island's row, the roster's +category pills. The `--anomaly-neon-*` tokens mirror the `--neon-*` ones name for +name so the mapping reads straight down. They are a **lighter** purple than +`--anomaly-accent`, and that is forced: cyan earns its prominence by contrast, +and the badge purple reads 3.3:1 against this panel — right as a stroke on the +dark collapse handle, unreadable as a heading. `#c9a5f0` puts back the 6.5:1 the +cyan had. + +That leaves two purples meaning "selected" inside the panel, which is deliberate: +the lavender is the **app** saying which tab or row you are on, while the green +ring on an anomaly card is the **game's**, and the card is a second view of the +game's own chooser. + +One thing is deliberately **not** re-pointed: the **board's own reds and ambers** +in the readout. They mean the same thing under every anomaly, and an anomaly is +precisely when a player most needs them to. + +Two of the three text values are **lifted** off what the game uses — its +secondary lavender measures 3.8:1 on this panel, under AA, the same trap +`--text-dim` was lifted out of once already. The cards on it are the game's, so the ground +under them goes along rather than leaving them floating on a navy belonging to +the rest of the panel. Each shell keeps its **own** alpha — the sheet 0.97, the +docked panel 0.88 — so the hue changes and how much board shows through does +not; and the docked foot darkens rather than keeping its navy tint, which would +fight the purple. It is a view, not a mode: nothing else in the app reads it. + +**Run in the HUD takes the colour too**, and Stop is untouched: a stop is +destructive of the run in progress whatever rules it began under. The docked panel's collapse handle takes it on the +same condition, which is what keeps it from ever sitting purple against a navy +panel. + +Run and the handle take **different purples**, and that is a contrast rule rather than a +taste one: `--anomaly-accent` is the game's badge purple for strokes and glyphs +on a dark ground, where brighter is more legible, while `--anomaly-action` is a +fill behind white text, where brighter is less — the badge purple carries white +at 4.0:1, so the filled pair sits two steps down the same ramp. + +That list also **stays compact until a card is chosen** — the unselected size is +the default and `.active` is what loosens it (a larger icon, roomier panels, and +the full rule). Three of the four describe a timeline nobody is in, and sizing it +this way makes the selected card obvious by shape as well as by colour, which is +the reading that survives a colourblind viewer. + ### Overflow and viewport rules Two overflow rules look like formatting and are not. Both were found by driving the diff --git a/docs/SOLVER.md b/docs/SOLVER.md index a294a83..185b431 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -8,7 +8,8 @@ the terminal. ``` packages/solver/ src/solver/ the engine: types, island split, simulation, distribution, search - src/data/ the authored building tables, the shipped island codes, unlock resolution + src/data/ the authored building tables, the anomaly table, the shipped island codes, + unlock resolution src/encoding/ the blueprint wire format src/utils/ the game's number ladder src/grid.ts tile predicates and the ASCII alphabet the tests are written in @@ -67,6 +68,71 @@ Search _tuning_ constants (`SWAP_RADIUS`, `POLISH_SHARE`, and the rest) delibera to `placementSearch.ts` rather than moving to `constants.ts`: they are meaningless apart from the stage they tune. +### `anomalies.ts` — the prestige rule changes + +A timeline runs under exactly one anomaly, and `"none"` is one of them rather than an absence, so +every consumer branches on a field instead of on `undefined`. `AnomalyDefinition` is a tagged +union over the **rule shape** (`baseline`, `shared_cooling`, `terrain_affinity`, +`role_isolation`), not over the anomaly's identity: the game ships these in batches and most of a +batch is an existing rule with different numbers, which this makes a table entry — while a +genuinely new rule is a variant the solver fails to compile without handling. + +Unlike `BUILDING_TABLE` beside it, the table is **hand-owned**. The game does ship an +`*AnomalySO` per anomaly and the external extractor dumps them alongside the roster — but all one +of those records carries is an id, four localized terms and the multipliers. Which *rule* a +multiplier belongs to is not in the data at all: a shared cooling pool and a shoreline bonus are +both "one double" to it. So the extract is what each entry's numbers and its quoted `description` +are checked against, and the `rule` tag stays a human's reading of the text. + +Two things about the shapes are load-bearing: + +- **Every stat anomaly is a _uniform_ scale.** "Energy, Heat, Cooling, and overheat capacity" is + four names for one field each across the four roles, so a bonus is a building whose whole tier + is worth more — `scaleEffectiveBuilding` is the one place that applies it, and one function + covers every such anomaly there will be. It is not free power: a scaled producer needs + proportionally more cooling and goes offline just as readily. +- **`role_isolation` depends on the layout, the other shapes do not.** A terrain bonus is fixed + per tile and can be folded into the board; a generator's isolation multiplier changes every + time the search moves a neighbour, so a placed building's figures have to be resolved per + layout. + +`shared_cooling` is the odd one out entirely, and it is the one that costs this package +something. **Its pool is the whole board** — the game's "island" is the map, Gale Hills and Ash +Bay, and "cooling does not carry over to other islands" means it does not carry to a different +map. So under Cryo Nexus the islands `splitGridIntoIslands` produces are **not independent**, +which is the assumption the worker pool, the per-island budget split, `IslandBest` and +`variants.ts` all rest on. + +What survives is more than it sounds. Heat stays adjacency-bound, so how much power an island can +make is still a question about that island alone; the islands are coupled by exactly two scalars, +the cooling they contribute and the waste they generate, and the board runs only if the first sums +over the second. An island is therefore no longer described by "its best power" but by a frontier — +power against net cooling it puts into or takes out of the pool — and the board-level problem is to +combine those frontiers under one scalar budget. That keeps the per-island parallelism; it changes +what a worker is asked for. + +Two smaller effects: the board is all-or-nothing **together** (every power source gets the same +percentage, and a partial percentage runs nothing), and patches below the minimum island size come +alive, since a lone tile's producer can be cooled from anywhere. The second is worth little — the +shipped maps lose 0 to 6 tiles that way, on boards of 49 to 184. + +`docs/game-logic.md` has the rules. Nothing about them is unconfirmed now: a pond is an obstacle +and grants no shore bonus, and the Tidal bonus lands on 36-44% of the grass of every shipped map. + +### `island.ts` — the window an island is solved in + +An `IslandSubGrid` is the component's bounding box **padded by one tile on every side**, carrying +the board's **real terrain** rather than a flattened "not mine" marker, plus a `buildable` mask. + +All three are for terrain rules. A rule can care what a building stands next to, a rock is not a +lake, and a building on the island's own edge has neighbours outside the component's bare box. +Real terrain in a padded window means a *neighbouring* island's grass falls inside it, so **which +tiles are this island's is the mask, never `type === "grass"`** — `buildIslandContext` takes it +and `tileCount` is the count that goes with it. + +The padding changes tile coordinates uniformly, so it changes no result: the fixtures reproduce +byte for byte across it. + ### `distribution.ts` — a port of Unity's `FlowNetwork.cs` Two shapes in it are load-bearing and look like details: diff --git a/docs/game-logic.md b/docs/game-logic.md index 9d7acc7..b307a6b 100755 --- a/docs/game-logic.md +++ b/docs/game-logic.md @@ -329,3 +329,150 @@ A few things always hold true no matter how you arrange things: And ultimately, what matters isn't how many buildings you've placed, or how much heat or cooling moved around the grid — it's the total amount of power actually being produced by whatever's still online at the end. + +--- + +## Anomalies + +Prestiging ("Time Jump") ends a timeline and starts a new one, and on the way +out the player picks an **anomaly**: a rule change that applies for the whole of +the next timeline. Exactly one is active at a time, and "no anomaly" is a real +option rather than a missing value — it means the rules exactly as described +above. + +An anomaly never adds a building, removes one, or changes the grid. It changes +either **what a placed building's figures are** or **how a supply gets shared +out**, and the three that exist so far divide cleanly along that line. + +### The figures an anomaly scales + +Every stat anomaly so far scales a building **uniformly**: the same multiplier +lands on all of its authored figures at once. The game's wording lists them +separately — "Energy, Heat, Cooling, and overheat capacity" — but those four +names are one field each across the four roles: + +| the game says | the figure | +| ------------------ | --------------------------------------------------- | +| Heat | a reactor's output, a generator's intake capacity, a direct producer's own heat | +| Energy | what a generator or direct producer puts out at full | +| Cooling | what a cooler absorbs | +| overheat capacity | the waste heat a generator or direct producer makes at full, and therefore what must be cooled for it to stay online | + +So a building with a ×1.67 bonus is simply a building whose whole tier is worth +1.67× as much: it absorbs more, produces more, and needs proportionally more +cooling. A bonus is **not** free power on its own — it moves the whole balance +of a layout, and a scaled producer that outruns its coolers goes offline like +any other. + +The multiplier is applied to the authored figure at solve time and is not +snapped back to the game's authored precision, because it is a runtime +multiply rather than a table entry. + +### Cryo Nexus — one shared cooling pool + +> All Heat Sinks on an island add their Cooling to one shared pool. It can cool +> every Power Source on that island, no matter how far away it is. If there is +> not enough Cooling, every Power Source receives the same percentage of what it +> needs. Each Heat Sink contributes ×0.88 of its normal Cooling. Cooling does +> not carry over to other islands. + +This replaces the cooling half of the distribution wholesale. Adjacency stops +mattering for cooling, and so do the fair split and the cleanup pass — there is +one number for the island and one rule for handing it out. Heat delivery is +untouched: reactors still only reach adjacent generators, and heat is still +settled first. + +Two consequences fall straight out of it, and both matter more than the rule +itself: + +- **Coolers stop competing for tile space near producers.** A cooler anywhere on + the island is worth exactly as much as one wedged between two generators, so + the layout problem collapses to "how much total cooling do I buy, and where do + I spend the tiles I have left". +- **Cooling becomes all-or-nothing for the whole island at once.** Every power + source gets the same percentage of what it needs, and a building only runs if + it is covered in full — so either the pool covers the island's entire waste + and everything runs, or it falls short and *every* power source on that island + shuts down. There is no partial board. + +The ×0.88 is a straight tax on each cooler's contribution, applied before +anything is shared. + +**"An island" here means the whole map.** Gale Hills is an island; so is Ash +Bay. The rule is board-wide: every cooler anywhere on the map pays into one +pool, and every power source anywhere on the map draws from it. "Cooling does +not carry over to other islands" means it does not carry to a *different map* — +there is nothing finer than the board for this pool to respect. + +This is the one rule in the game that reaches across the whole board, and every +other rule in this document is the opposite: adjacency-bound, which is why a +board splits into independent patches of connected grass and each can be solved +on its own. Under this anomaly they are not independent. + +They are still *nearly* independent, and the shape of what survives is worth +being precise about, because it is the whole basis of solving this efficiently: + +- **Heat is untouched.** Reactors still only reach adjacent generators, so which + buildings a patch can bring online, and how much power it makes, is still a + question about that patch alone. +- **The patches are coupled by exactly two numbers.** A layout on a patch makes + some power, generates some waste, and contributes some cooling; the board runs + on whether the cooling *summed over every patch* covers the waste summed over + every patch. Nothing else crosses. + +So a patch is no longer described by "the most power it can make" but by a +trade-off: how much power it makes for a given amount of cooling it puts into +the pool over what it takes out. It can run a producer it cannot cool and let +the rest of the board pay for it, or pave itself in coolers and produce nothing +but surplus. + +And because cooling is all-or-nothing, and every power source gets the same +percentage, **the whole board is all-or-nothing together**: either the pool +covers the board's entire waste and everything runs, or it falls short and every +power source on the map produces nothing. There is no partial board and no +partially-lit patch. + +One smaller consequence: a patch too small to work normally becomes usable, since +it no longer needs a cooler of its own. A lone grass tile with a direct producer +on it runs off the pool. That is worth having but it is not much — the shipped +maps lose between 0 and 6 tiles to patches below the minimum size, out of boards +of 49 to 184 grass tiles. + +### Tidal Ascendancy — a bonus for building on the shore + +> Production buildings next to water get a ×1.67 multiplier. Corners count too. +> The bonus affects Energy, Heat, Cooling, and overheat capacity. Buildings away +> from water work normally and get no bonus. + +A per-tile uniform scale, decided by terrain alone: a building is scaled if any +of the eight tiles around it is water. Nothing about the layout can change which +tiles qualify, so this is a fixed per-tile multiplier the solver can compute once +per island and then treat as part of the board. + +It is the first rule in the game where a non-buildable tile does anything at all. + +**A pond is not water.** It is an obstacle — scenery you cannot build on, like a +rock or a tree — and it grants no shore bonus. Only the water tile proper does. + +The bonus is not a rare one: between 36% and 44% of the grass on every shipped +map is water-adjacent, so on any of them something close to half the buildable +board is worth ×1.67. That makes the shoreline the most valuable ground on the +map and this anomaly a genuine change of shape rather than a modifier, since the +best layout under it wants its producers on a coastline whose length is fixed by +the terrain. + +### Singularity Isolation — generators want elbow room + +> A Generator with no other Generator next to it gets ×2.5 Energy output, Heat +> output, and overheat capacity. If another Generator touches it, including at a +> corner, those values drop to ×0.8. More neighbours do not make the penalty +> worse. + +Unlike the other two, this one depends on **the layout itself**: a generator's +figures are a function of what its neighbours are, so they change as the search +moves buildings around. It cannot be folded into the board or into the roster, +and it is the reason a placed building's resolved figures have to be computed +per layout rather than once per tile. + +The threshold is binary — one adjacent generator costs the same as five — so the +rule is a two-way test, not a count. diff --git a/packages/solver/fixtures/full_upgrades_seed1.json b/packages/solver/fixtures/full_upgrades_seed1.json index 4f8bf91..d94b211 100755 --- a/packages/solver/fixtures/full_upgrades_seed1.json +++ b/packages/solver/fixtures/full_upgrades_seed1.json @@ -6,7 +6,7 @@ "grid": { "width": 4, "height": 4, - "code": "eJxjYWFEAwABJgAZ" + "code": "eJxjZGFhRAMAAToAGg" }, "unlocked_upgrades": { "antimatter_plant": 5, diff --git a/packages/solver/fixtures/no_buildable_tiles.json b/packages/solver/fixtures/no_buildable_tiles.json index f3ea9d1..202f179 100755 --- a/packages/solver/fixtures/no_buildable_tiles.json +++ b/packages/solver/fixtures/no_buildable_tiles.json @@ -6,7 +6,7 @@ "grid": { "width": 4, "height": 3, - "code": "eJxjYWZAAgAAbQAI" + "code": "eJxjZGFmQAIAAH0ACQ" }, "unlocked_upgrades": { "antimatter_plant": 5, diff --git a/packages/solver/fixtures/single_tile.json b/packages/solver/fixtures/single_tile.json index e4afa5e..7c23e7d 100755 --- a/packages/solver/fixtures/single_tile.json +++ b/packages/solver/fixtures/single_tile.json @@ -6,7 +6,7 @@ "grid": { "width": 1, "height": 1, - "code": "eJxjZGQEAAAJAAQ" + "code": "eJxjZGRkBAAADgAF" }, "unlocked_upgrades": { "antimatter_plant": 5, diff --git a/packages/solver/fixtures/small_grid_seed42.json b/packages/solver/fixtures/small_grid_seed42.json index 0d23565..4fa05a2 100755 --- a/packages/solver/fixtures/small_grid_seed42.json +++ b/packages/solver/fixtures/small_grid_seed42.json @@ -6,7 +6,7 @@ "grid": { "width": 3, "height": 3, - "code": "eJxjZmaEAQAAdwAQ" + "code": "eJxjZGZmhAEAAIQAEQ" }, "unlocked_upgrades": { "antimatter_plant": 5, diff --git a/packages/solver/fixtures/three_island_uneven.json b/packages/solver/fixtures/three_island_uneven.json index bb6711c..94f7838 100755 --- a/packages/solver/fixtures/three_island_uneven.json +++ b/packages/solver/fixtures/three_island_uneven.json @@ -6,7 +6,7 @@ "grid": { "width": 13, "height": 4, - "code": "eJzjZWFkZGBkhBKMKBwGBlQOA4gEcwAH7QA4" + "code": "eJxj5GVhZGRgZIQSjCgcBgZUDgOIBHMACCUAOQ" }, "unlocked_upgrades": { "antimatter_plant": 5, diff --git a/packages/solver/fixtures/two_island_seed7.json b/packages/solver/fixtures/two_island_seed7.json index 98785ff..8826015 100755 --- a/packages/solver/fixtures/two_island_seed7.json +++ b/packages/solver/fixtures/two_island_seed7.json @@ -6,7 +6,7 @@ "grid": { "width": 7, "height": 3, - "code": "eJxjZ2ZkZGRgBAMkCgABwAAd" + "code": "eJxjZGdmZGRkYAQDJAoAAdkAHg" }, "unlocked_upgrades": { "antimatter_plant": 5, diff --git a/packages/solver/src/data/anomalies.ts b/packages/solver/src/data/anomalies.ts new file mode 100644 index 0000000..d5fb3c3 --- /dev/null +++ b/packages/solver/src/data/anomalies.ts @@ -0,0 +1,99 @@ +import type { AnomalyDefinition, AnomalyId } from "../solver/types"; + +/** + * The anomalies a timeline can be run under. + * + * Transcribed by hand from the anomaly record the external extractor reads out + * of the game alongside the roster — each entry there carries the game's + * `{0}`-templated strings and the `values` that fill them, and both halves land + * here with the placeholders resolved. Unlike `BUILDING_TABLE` next door, + * **nothing splices this file**: the extractor writes its record and the icons + * and stops, so the table is hand-owned and a new anomaly has to be brought + * across by hand. The ids are the extractor's (`CryoNexusAnomalySO` -> + * `cryo_nexus`), so they match without translation. + * + * `docs/game-logic.md` is the authority on what the rules *mean* — including + * the two things the description text does not say outright: that "an island" + * is the whole map, and that a pond is an obstacle rather than water. + * + * `"none"` leads the table, is not in the extractor's output, and is a real + * entry rather than an absence — see `AnomalyId`. Its wording is ours. + */ +export const ANOMALIES: readonly AnomalyDefinition[] = [ + { + id: "none", + rule: "baseline", + name: "No Anomaly", + benefit: "The ordinary rules", + drawback: "No bonus either", + description: + "The timeline runs under the ordinary rules. Nothing is modified: " + + "every building is worth exactly what its tier says, and heat and " + + "cooling reach only the tiles beside them.", + }, + { + id: "cryo_nexus", + rule: "shared_cooling", + name: "Cryo Nexus", + benefit: "Cooling is shared per island", + drawback: "Heat Sink cooling ×0.88", + description: + "All Heat Sinks on an island add their Cooling to one shared pool. It " + + "can cool every Power Source on that island, no matter how far away it " + + "is. If there is not enough Cooling, every Power Source receives the " + + "same percentage of what it needs. Each Heat Sink contributes ×0.88 of " + + "its normal Cooling. Cooling does not carry over to other islands.", + contribution: 0.88, + }, + { + id: "tidal_ascendancy", + rule: "terrain_affinity", + name: "Tidal Ascendancy", + benefit: "Waterside buildings ×1.67", + drawback: "Inland buildings gain nothing", + description: + "Production buildings next to water get a ×1.67 multiplier. Corners " + + "count too. The bonus affects Energy, Heat, Cooling, and overheat " + + "capacity. Buildings away from water work normally and get no bonus.", + // Water only. A pond looks wet and is not: the game files it with the rocks + // and the trees as an obstacle, and it grants nothing. Between 36% and 44% + // of the grass on every shipped map is water-adjacent, so this list decides + // the rating of roughly half the board — it is the cheapest thing here to + // get wrong. + terrain: ["water"], + multiplier: 1.67, + }, + { + id: "singularity_isolation", + rule: "role_isolation", + name: "Singularity Isolation", + benefit: "Isolated generators ×2.5", + drawback: "Adjacent generators ×0.8", + description: + "A Generator with no other Generator next to it gets ×2.5 Energy " + + "output, Heat output, and overheat capacity. If another Generator " + + "touches it, including at a corner, those values drop to ×0.8. More " + + "neighbours do not make the penalty worse.", + role: "generator", + isolated: 2.5, + crowded: 0.8, + }, +]; + +/** The anomaly a timeline runs under until the player picks otherwise. */ +export const DEFAULT_ANOMALY_ID: AnomalyId = "none"; + +/** + * The definition for an id, falling back to the baseline. + * + * Total on purpose: the id crosses `localStorage` and the worker boundary, so + * it can arrive as something this build has never heard of — a save written by + * a later version, most likely. Running the base rules is the honest answer to + * that, and it is the one an unrecognised id would have meant anyway. + */ +export function getAnomaly(id: string | undefined): AnomalyDefinition { + return ( + ANOMALIES.find((a) => a.id === id) ?? + ANOMALIES.find((a) => a.id === DEFAULT_ANOMALY_ID)! + ); +} diff --git a/packages/solver/src/data/effectiveBuildings.ts b/packages/solver/src/data/effectiveBuildings.ts index c236cf8..555d43e 100755 --- a/packages/solver/src/data/effectiveBuildings.ts +++ b/packages/solver/src/data/effectiveBuildings.ts @@ -4,6 +4,7 @@ import type { DirectProducerDefinition, EffectiveBuilding, GeneratorDefinition, + PrestigeScales, } from "../solver/types"; /** @@ -15,10 +16,18 @@ import type { * figures are zero. Generators and direct producers carry the game's authored * `EnergyPerTick` / `WasteHeatPerTick` for the resolved tier, and only fall back * to `heat - energy` for a source that authors no waste at all. + * + * `prestige` folds the player's Time Lab research in — see `prestigeScales`. + * It belongs here rather than anywhere downstream because it is a property of + * the *roster*, not of a layout or a tile: by the time an `EffectiveBuilding` + * crosses the worker boundary the research is already in its three numbers, and + * nothing in the solver has to know the Time Lab exists. Omitted means + * unresearched. */ export function getEffectiveBuildings( buildings: readonly BuildingDefinition[], unlockedUpgrades: Record, + prestige?: PrestigeScales, ): EffectiveBuilding[] { const result: EffectiveBuilding[] = []; @@ -52,13 +61,19 @@ export function getEffectiveBuildings( effectiveValue = building.levels[clamped].cooling; } - result.push({ - id: building.id, - type: building.type, - effectiveValue, - energy, - waste, - }); + result.push( + scaleEffectiveBuilding( + { + id: building.id, + type: building.type, + effectiveValue, + energy, + waste, + baseValue: effectiveValue, + }, + prestige?.[building.type] ?? 1, + ), + ); } return result; @@ -129,11 +144,18 @@ export function rosterCanProducePower( * * Both `simulatePlacedBuildings` and the board readout go through here, so the * panel cannot rate a building differently from the scorer that ran it. + * + * `prestige` applies here too, and must: a placement stores the **authored** + * tier value, which is what identifies the tier and is deliberately not + * rewritten when research lands. So the research is applied on the way out, + * every time, and both callers pass the same scales the solver was handed. */ export function effectiveAtValue( def: BuildingDefinition, baseValue: number, + prestige?: PrestigeScales, ): EffectiveBuilding { + const factor = prestige?.[def.type] ?? 1; if (def.type === "generator" || def.type === "direct_producer") { const levels = (def as GeneratorDefinition | DirectProducerDefinition) .levels; @@ -144,19 +166,66 @@ export function effectiveAtValue( break; } } - return { + return scaleEffectiveBuilding( + { + id: def.id, + type: def.type, + effectiveValue: tier.heat, + energy: tier.energy, + waste: tier.waste ?? snapToAuthoredPrecision(tier.heat - tier.energy), + baseValue: tier.heat, + }, + factor, + ); + } + return scaleEffectiveBuilding( + { id: def.id, type: def.type, - effectiveValue: tier.heat, - energy: tier.energy, - waste: tier.waste ?? snapToAuthoredPrecision(tier.heat - tier.energy), - }; - } + effectiveValue: baseValue, + energy: 0, + waste: 0, + baseValue, + }, + factor, + ); +} + +/** + * Re-rates a resolved building by a uniform multiplier. + * + * Every anomaly that changes a building's numbers scales **all three of them by + * the same factor** — the game lists "Energy, Heat, Cooling, and overheat + * capacity" separately, but those four names are one field each across the four + * roles, so a bonus is simply a building whose whole tier is worth more. That + * is why one function covers both stat anomalies and will cover the next one. + * + * Which means a multiplier is *not* free power: a scaled producer absorbs more, + * makes more, and needs proportionally more cooling, so it goes offline exactly + * as readily as an unscaled one. Only `wasteIsCovered` decides that, and it is + * relative. + * + * The product is left unsnapped, unlike the authored figures it comes from: the + * game applies this at runtime rather than authoring a table entry for it, so + * `snapToAuthoredPrecision` would be inventing a rounding the game does not do. + * + * Returns the building unchanged at a factor of 1, so the common case allocates + * nothing and the identity holds by reference. + */ +export function scaleEffectiveBuilding( + building: EffectiveBuilding, + factor: number, +): EffectiveBuilding { + if (factor === 1) return building; return { - id: def.id, - type: def.type, - effectiveValue: baseValue, - energy: 0, - waste: 0, + id: building.id, + type: building.type, + effectiveValue: building.effectiveValue * factor, + energy: building.energy * factor, + waste: building.waste * factor, + // Carried through untouched, and that is the point of it: it identifies the + // tier, and a tier does not change because something scaled what it is + // worth. See `EffectiveBuilding.baseValue`. + baseValue: building.baseValue, }; } diff --git a/packages/solver/src/data/prestige.ts b/packages/solver/src/data/prestige.ts new file mode 100644 index 0000000..9b2b85c --- /dev/null +++ b/packages/solver/src/data/prestige.ts @@ -0,0 +1,136 @@ +import type { + BuildingType, + PrestigeScales, + PrestigeUpgrade, +} from "../solver/types"; + +/** + * The Time Lab researches that change what a building is worth. + * + * Transcribed by hand from the external extractor's Time Lab record, the way + * `ANOMALIES` is, and like that table nothing splices this one — the extractor + * writes its record and the icons and stops. Each entry's `bonuses` is that + * record's own `values` array, its authored `BonusPercentage`; the record also + * carries a `multiplier` array that is just `1 + values`, and it is not copied, + * because deriving it is what keeps the two from drifting. + * + * **Three of the game's ten are here.** The other seven change the economy + * around the board rather than anything on it, and the solver cannot feel any + * of them — they are listed so it is clear they were read and dismissed rather + * than missed: + * + * - `archives_beyond_time` — research point income + * - `chrono_accelerator` — research time + * - `chronon_reactor` — chronons earned per timeline + * - `erased_from_time` / `phase_breaker` — obstacle removal time and cost + * - `quantum_replicator` — building prices + * - `zero_time_protocol` — free research speed-up window + * + * The solver optimises power on a fixed board from a fixed roster. What a + * building costs, how long an obstacle takes to clear and how fast research + * completes are all decisions made *before* it is handed a board, so none of + * them can change which layout is best. `quantum_replicator` is the one worth + * a second look — it is the only one that touches buildings at all — but it + * moves their price, and price is not an input to any figure the solver + * computes. + */ +export const PRESTIGE_UPGRADES: readonly PrestigeUpgrade[] = [ + { + id: "absolute_zero", + name: "Absolute Zero", + effect: "Cooling Output", + description: "Boosts the Cooling output of all cooling systems.", + roles: ["cooler"], + bonuses: [1.0, 1.5, 2.25, 3.0, 4.0], + }, + { + id: "infinite_grid", + name: "Infinite Grid", + effect: "Power Source Stats", + description: + "Improves all Generators and Wind Turbines, increasing Energy output, " + + "Heat, and overheat thresholds.", + // The three figures it names are `EffectiveBuilding`'s three, so this is a + // uniform scale like the stat anomalies -- and like them, not free power: + // the overheat threshold it raises is the waste that must then be cooled. + roles: ["generator", "direct_producer"], + bonuses: [1.0, 1.5, 2.25, 3.0, 4.0], + }, + { + id: "stellar_forge", + name: "Stellar Forge", + effect: "Heat Output", + description: "Boosts the Heat output of all Heat Producers.", + /* + * Reactors only — and this is the **one uncertain call in this file**. + * + * The game's own category `heat_producer` holds reactors *and* direct + * producers (see `BuildingDefinition`), so "all Heat Producers" read + * literally would take the wind turbine too. Against that: `infinite_grid` + * above goes out of its way to name "Generators and Wind Turbines", which + * is only worth saying if the turbine is not already covered here, and + * scaling a direct producer's heat alone would be the only non-uniform + * effect in the game -- its energy and waste are authored separately and + * this names neither. + * + * If it does cover turbines, the cost of being wrong is close to nothing: + * the wind turbine is the weakest building in the roster and, per + * `docs/game-logic.md`, almost never earns a tile. Worth confirming in + * game all the same. + */ + roles: ["reactor"], + bonuses: [1.0, 1.5, 2.25, 3.0, 4.0], + }, +]; + +/** The factor a bonus buys. The game authors the bonus; this is the derivation. */ +export function prestigeMultiplier(bonus: number): number { + return 1 + bonus; +} + +const UNSCALED: PrestigeScales = { + cooler: 1, + reactor: 1, + generator: 1, + direct_producer: 1, +}; + +/** + * Folds researched levels into one factor per role. + * + * `levels` maps an upgrade id to its researched level **index**, and follows + * `buildingUpgrades` exactly: a key's presence is what "researched" means, and + * un-researching deletes the key rather than storing a zero. So 0 is the first + * level, not the absence of one — the same convention, because these are picked + * with the same control and confusing the two would silently rate a board at the + * wrong level rather than failing. + * + * An index past the table clamps rather than throwing, since this arrives from + * `localStorage`. + * + * Two upgrades touching one role would multiply; none do today, and writing it + * as a product rather than an assignment is what keeps that true when one does. + */ +export function prestigeScales( + levels: Record | undefined, +): PrestigeScales { + if (!levels) return UNSCALED; + + const scales: Record = { ...UNSCALED }; + let touched = false; + + for (const upgrade of PRESTIGE_UPGRADES) { + if (levels[upgrade.id] === undefined) continue; + const idx = Math.max( + 0, + Math.min(levels[upgrade.id], upgrade.bonuses.length - 1), + ); + const factor = prestigeMultiplier(upgrade.bonuses[idx]); + for (const role of upgrade.roles) scales[role] *= factor; + touched = true; + } + + // The identity by reference, so an unresearched roster costs nothing and the + // common case can be compared cheaply. + return touched ? scales : UNSCALED; +} diff --git a/packages/solver/src/encoding/blueprint.ts b/packages/solver/src/encoding/blueprint.ts index 0d8357c..f6d4b25 100755 --- a/packages/solver/src/encoding/blueprint.ts +++ b/packages/solver/src/encoding/blueprint.ts @@ -10,10 +10,25 @@ import type { PlacedBuilding, Tile, TileType } from "../solver/types"; * * Wire format, before deflate + base64url: * - * byte 0 grid width - * byte 1 grid height - * byte 2.. one byte per tile, row-major + * byte 0 format version + * byte 1 grid width + * byte 2 grid height + * byte 3.. one byte per tile, row-major * [tier table] optional, see below + * [rules] optional, see below + * + * **The version byte, and how a code without one is recognised.** Codes written + * before it existed begin with the width, so the two are told apart by value + * rather than by a flag: a board is at least `MIN_GRID_DIM` (5) tiles on a + * side, so a first byte *below* that cannot be a width and is a version. + * Versions therefore have five usable values before the trick runs out, which + * is ample for a format whose last two changes were both appended optional + * sections — and if it ever is not, version 4 can spend a byte on a longer + * header. + * + * This is why `MIN_GRID_DIM` lives in this package rather than in the size + * stepper that enforces it: it stopped being a UI limit the moment the wire + * format began reading codes by it. * * Each tile byte is either a terrain id (0-6) or a building id (10+). A * building byte implies the tile beneath it is grass, which is the only terrain @@ -48,6 +63,29 @@ import type { PlacedBuilding, Tile, TileType } from "../solver/types"; * `#applySaved` and `rebasePlacements` both apply. Freezing tiers into the * save would put those two in permanent disagreement. * + * **The rules section.** Tiers say what the buildings were, and that is no + * longer the whole story: the timeline's anomaly and its Time Lab research + * change what those same tiers are worth, so a board shared out of a + * ×5-cooling timeline is not the board a reader without that research would + * get. One more optional section carries them: + * + * byte a anomaly id byte + * byte a+1 m — how many research entries follow + * then m pairs [research byte][level index] + * + * It follows the tier table and is read only after it, so **a code carrying + * rules must carry a tier count byte first**, even a zero one. That costs a + * byte and buys a strictly sequential parse; writing a zero count is not a + * contradiction, since an empty table already means "tiers unknown" rather + * than "tiers are zero". + * + * Optional in both directions on the same terms as the tier table: an older + * code stops earlier, an older reader stops earlier, and **no rules section + * means "rules unknown"** — never "no anomaly and no research", which is a + * thing a code can also say explicitly and is not the same claim. Share codes + * carry it; saved layouts do not, for the reason above — a save should pick up + * the research bought since, not argue with it. + * * This is the only layout format there is: island templates (`data/maps.ts`), * the app's saved layouts, share codes and the golden fixtures all use it. * @@ -58,6 +96,66 @@ import type { PlacedBuilding, Tile, TileType } from "../solver/types"; * every shipped building to a round-trip for exactly that reason. */ +/** + * The current format version, written as byte 0 of every new code. + * + * Bump it only for a change a reader cannot absorb by stopping early — the + * optional-section rule below covers appending, which is why this is still 1 + * after two sections were added. A code naming a version this build does not + * know is refused rather than guessed at: the tiles are positional, so reading + * a later payload as this one would not fail, it would quietly produce a + * different board. + */ +export const BLUEPRINT_VERSION = 1; + +/** + * The smallest a board may be on either side, as the size stepper enforces it. + * + * It is a **wire constant** now rather than a UI one, because it is what makes + * a version byte distinguishable from the width byte an older code starts with. + * The claim it rests on is historical and settled: every code written before + * versioning came from a board at least this wide — the shipped islands are 13 + * across at the narrowest, a blank custom island is 10, and the stepper has + * always clamped here. + * + * Note it constrains **old codes only**. A versioned code says its width in + * byte 1, where no value is ambiguous, so the fixtures' 4x4 test boards encode + * and decode perfectly well. Lowering this would not shrink any board; it would + * only make some old codes unreadable. + */ +export const MIN_GRID_DIM = 5; + +/** + * Anomaly and research ids, as bytes. + * + * The same **compatibility surface** as the building map below: renumber one + * and a code shared last month decodes as a different anomaly rather than + * failing. Append; never renumber. Both start their real entries at 1, leaving + * 0 free — for anomalies it is the genuine "no anomaly" choice, and for + * research it is a value no writer emits, so a stray zero decodes as nothing + * rather than as the first upgrade. + */ +const ANOMALY_BYTE_MAP: Record = { + none: 0, + cryo_nexus: 1, + tidal_ascendancy: 2, + singularity_isolation: 3, +}; + +const RESEARCH_BYTE_MAP: Record = { + absolute_zero: 1, + infinite_grid: 2, + stellar_forge: 3, +}; + +const REV_ANOMALY_BYTE_MAP: Record = Object.fromEntries( + Object.entries(ANOMALY_BYTE_MAP).map(([id, byte]) => [byte, id]), +); + +const REV_RESEARCH_BYTE_MAP: Record = Object.fromEntries( + Object.entries(RESEARCH_BYTE_MAP).map(([id, byte]) => [byte, id]), +); + /** Terrain ids. Buildings start at 10 so the two ranges never collide. */ const TILE_BYTE_MAP: Record = { water: 0, @@ -145,6 +243,19 @@ export type BlueprintPlacement = Pick; */ export type BlueprintTiers = Record; +/** + * The rules a board was built under: which anomaly was running and what Time + * Lab research the author had. + * + * `research` is keyed and indexed exactly like the player's own record — the + * level **index**, and a key's presence is what "researched" means, so an id + * that is absent was not researched at all. + */ +export interface BlueprintRules { + anomalyId: string; + research: Record; +} + // --- Browser Compression & Base64URL Helpers --- /** @@ -221,6 +332,7 @@ function buildPayload( grid: Tile[][], placements: readonly BlueprintPlacement[], tiers?: BlueprintTiers, + rules?: BlueprintRules, ): Uint8Array { const height = grid.length; const width = grid[0].length; @@ -258,15 +370,46 @@ function buildPayload( } } + /* + * The rules section, in the order the reader walks it. Research entries go + * out in byte order for the same reason the tier table does: two encodes of + * one board must produce one payload, and object key order is not something + * to rely on for that. + */ + const ruleBytes: number[] = []; + if (rules) { + const research: number[] = []; + for (const [id, byte] of Object.entries(RESEARCH_BYTE_MAP).sort( + (a, b) => a[1] - b[1], + )) { + const level = rules.research[id]; + if (level === undefined) continue; + research.push(byte, Math.max(0, Math.min(255, Math.round(level)))); + } + ruleBytes.push( + ANOMALY_BYTE_MAP[rules.anomalyId] ?? ANOMALY_BYTE_MAP.none, + research.length / 2, + ...research, + ); + } + + // A rules section is read only after a tier count byte, so writing one means + // writing that byte even when there are no tiers to declare. Zero there says + // "tiers unknown", which is true and is what it has always meant. + const writeTable = table.length > 0 || ruleBytes.length > 0; + const payload = new Uint8Array( - 2 + tiles.length + (table.length > 0 ? 1 + table.length : 0), + 3 + tiles.length + (writeTable ? 1 + table.length : 0) + ruleBytes.length, ); - payload[0] = width; - payload[1] = height; - payload.set(tiles, 2); - if (table.length > 0) { - payload[2 + tiles.length] = table.length / 2; - payload.set(table, 3 + tiles.length); + payload[0] = BLUEPRINT_VERSION; + payload[1] = width; + payload[2] = height; + payload.set(tiles, 3); + if (writeTable) { + payload[3 + tiles.length] = table.length / 2; + payload.set(table, 4 + tiles.length); + if (ruleBytes.length > 0) + payload.set(ruleBytes, 4 + tiles.length + table.length); } return payload; @@ -301,17 +444,26 @@ export function blueprintKey( } /** - * Encodes a layout. Pass `tiers` to record what each building was rated for — - * share codes do, saved layouts do not; see the module header. + * Encodes a layout. Pass `tiers` to record what each building was rated for and + * `rules` to record the anomaly and research it was built under — share codes + * pass both, saved layouts pass neither; see the module header. + * + * The argument order is the payload order, which is also the order they may be + * dropped in: a code can carry tiers without rules, but not rules without a + * tier count byte ahead of them. Passing `rules` alone is legal and writes that + * byte as zero, meaning "tiers unknown". */ export async function encodeBlueprint( grid: Tile[][], placements: readonly BlueprintPlacement[] = [], tiers?: BlueprintTiers, + rules?: BlueprintRules, ): Promise { if (!grid || !grid.length || !grid[0]?.length) return ""; - const compressed = await compress(buildPayload(grid, placements, tiers)); + const compressed = await compress( + buildPayload(grid, placements, tiers, rules), + ); return uint8ArrayToBase64Url(compressed); } @@ -327,6 +479,16 @@ export interface DecodedBlueprint { * "everything is at tier 0". */ tiers: BlueprintTiers; + /** + * The anomaly and research the board was built under, or **null** where the + * code does not say — which every code written before this section existed + * is, and every saved layout still is. + * + * Null rather than a zeroed record, because "the author had no anomaly and no + * research" is a different claim a code can also make explicitly, and a + * reader that confused the two would rate someone else's board at nothing. + */ + rules: BlueprintRules | null; } /** @@ -338,15 +500,36 @@ export interface DecodedBlueprint { */ export async function decodeBlueprint(code: string): Promise { if (!code) - return { width: 0, height: 0, grid: [], placements: [], tiers: {} }; + return { + width: 0, + height: 0, + grid: [], + placements: [], + tiers: {}, + rules: null, + }; const compressedBytes = base64UrlToUint8Array(code); const data = await decompress(compressedBytes); - const width = data[0]; - const height = data[1]; + /* + * Versioned or not, told apart by value — see the module header. A first byte + * below `MIN_GRID_DIM` is too small to be a width, so it is a version; at or + * above it, this is a code from before the byte existed and that value is the + * width. + */ + const versioned = data[0] < MIN_GRID_DIM; + if (versioned && data[0] !== BLUEPRINT_VERSION) { + throw new Error( + `Blueprint is format version ${data[0]}; this build reads ${BLUEPRINT_VERSION}`, + ); + } + + const head = versioned ? 3 : 2; + const width = data[head - 2]; + const height = data[head - 1]; - if (!width || !height || data.length < 2 + width * height) { + if (!width || !height || data.length < head + width * height) { throw new Error("Blueprint payload is truncated or malformed"); } @@ -356,7 +539,7 @@ export async function decodeBlueprint(code: string): Promise { for (let y = 0; y < height; y++) { const row: Tile[] = []; for (let x = 0; x < width; x++) { - const byteVal = data[2 + (y * width + x)]; + const byteVal = data[head + (y * width + x)]; const buildingId = REV_BUILDING_BYTE_MAP[byteVal]; if (buildingId !== undefined) { @@ -378,9 +561,10 @@ export async function decodeBlueprint(code: string): Promise { * reconstructed, and they have already been read. */ const tiers: BlueprintTiers = {}; - const tableAt = 2 + width * height; + const tableAt = head + width * height; + let entries = 0; if (data.length > tableAt) { - const entries = data[tableAt]; + entries = data[tableAt]; for (let i = 0; i < entries; i++) { const at = tableAt + 1 + i * 2; if (at + 1 >= data.length) break; @@ -389,7 +573,33 @@ export async function decodeBlueprint(code: string): Promise { } } - return { width, height, grid, placements, tiers }; + /* + * The rules section, which begins past the tier table the count byte + * *declared* — not past the entries that fitted. A truncated table therefore + * takes the rules with it rather than letting the walk resume at an offset + * that means nothing, which is the same "give up the rest, keep the tiles" + * rule the table itself follows. + */ + let rules: BlueprintRules | null = null; + const rulesAt = tableAt + 1 + entries * 2; + if (data.length > tableAt && data.length > rulesAt) { + const research: Record = {}; + const count = rulesAt + 1 < data.length ? data[rulesAt + 1] : 0; + for (let i = 0; i < count; i++) { + const at = rulesAt + 2 + i * 2; + if (at + 1 >= data.length) break; + const id = REV_RESEARCH_BYTE_MAP[data[at]]; + if (id !== undefined) research[id] = data[at + 1]; + } + rules = { + // An anomaly byte this build has never heard of reads as none, the same + // fallback `getAnomaly` makes for an unrecognised id. + anomalyId: REV_ANOMALY_BYTE_MAP[data[rulesAt]] ?? "none", + research, + }; + } + + return { width, height, grid, placements, tiers, rules }; } /** @@ -412,6 +622,23 @@ export async function decodeBlueprint(code: string): Promise { * two, the first wins, which is the same arbitrary-but-stable answer the wire * format could hold anyway. */ +/** + * The rules record for a share code: the anomaly running and the research the + * player has. + * + * Beside `placementTiers` below and for the same reason — the app's Share + * button and the CLI both write share codes, and neither should be spelling + * this out itself. Research is copied rather than filtered: an id this build + * does not carry a byte for is simply dropped at encode time, which is where + * the format's own compatibility rule lives. + */ +export function blueprintRules( + anomalyId: string, + research: Record, +): BlueprintRules { + return { anomalyId, research: { ...research } }; +} + export function placementTiers( placements: readonly PlacedBuilding[], ): BlueprintTiers { diff --git a/packages/solver/src/index.ts b/packages/solver/src/index.ts index a18105a..a7d0e90 100644 --- a/packages/solver/src/index.ts +++ b/packages/solver/src/index.ts @@ -28,9 +28,11 @@ export * from "./solver/alternates"; export * from "./solver/report"; export * from "./solver/solver"; +export * from "./data/anomalies"; export * from "./data/buildings"; export * from "./data/effectiveBuildings"; export * from "./data/maps"; +export * from "./data/prestige"; export * from "./encoding/blueprint"; export * from "./utils/formatters"; diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index d01351a..b2c8daa 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -1,5 +1,6 @@ +import { getAnomaly } from "../data/anomalies"; import { CHEBYSHEV_DIRECTIONS, spatialKeyCompare } from "./constants"; -import type { Tile } from "./types"; +import type { AnomalyDefinition, Tile } from "./types"; /** * Scratch buffers reused by every `runDistribution` call on one island. @@ -100,6 +101,20 @@ export class DistributionScratch { export interface IslandContext { /** Number of buildable (grass) tiles. */ readonly n: number; + /** + * The rules this island is being solved under. Always present — `"none"` is + * an anomaly like any other, so no stage has to branch on `undefined`. + * + * It rides on the context rather than being passed down the stages because + * every stage already has one, and the two anomalies that will need a board + * to answer (a shore bonus, a shared cooling pool) will want it exactly here, + * beside the tile index and the adjacency they are defined over. + * + * **Nothing reads it yet.** The rules are not implemented — see + * `docs/game-logic.md`. It is threaded so that implementing them is a change + * to the stages and not to every signature between here and the worker. + */ + readonly anomaly: AnomalyDefinition; readonly xs: Int32Array; readonly ys: Int32Array; /** Chebyshev-adjacent buildable tiles of each tile, ascending index order. */ @@ -121,12 +136,29 @@ export interface IslandContext { readonly wasteTiles: Int32Array; } -/** Builds the tile index and adjacency for one island's grass tiles. */ -export function buildIslandContext(localGrid: Tile[][]): IslandContext { +/** + * Builds the tile index and adjacency for one island's buildable tiles. + * + * `buildable` is an `IslandSubGrid`'s mask, indexed `y * width + x` over the + * same window. Pass it whenever the grid is an island window: the window holds + * the board's real terrain, so a *neighbouring* island's grass sits inside it + * and is grass, and without the mask those tiles would join this island's + * index. Omitted, every grass tile counts — which is what a caller handing over + * a whole grid of its own means. + */ +export function buildIslandContext( + localGrid: Tile[][], + buildable?: Uint8Array, + anomaly: AnomalyDefinition = getAnomaly(undefined), +): IslandContext { + const gridWidth = localGrid[0]?.length ?? 0; const coords: [number, number][] = []; for (const row of localGrid) { for (const tile of row) { - if (tile.type === "grass") coords.push([tile.x, tile.y]); + const mine = buildable + ? buildable[tile.y * gridWidth + tile.x] === 1 + : tile.type === "grass"; + if (mine) coords.push([tile.x, tile.y]); } } coords.sort(spatialKeyCompare); @@ -165,6 +197,7 @@ export function buildIslandContext(localGrid: Tile[][]): IslandContext { return { n, + anomaly, xs, ys, neighbors, diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index 05e398e..ba5e40b 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -114,14 +114,25 @@ export function splitGridIntoIslands( if (component.length < minTilesRequired) continue; - // 3. Extract bounding dimensions and build the sub-grid mapping - const subWidth = maxX - minX + 1; - const subHeight = maxY - minY + 1; + // 3. Window the board around the component and build the sub-grid. + // + // Padded by one tile on every side so that every neighbour of every + // island tile is inside the window: a rule that reads terrain (a shore + // bonus, say) has to be answerable for a building on the island's own + // edge, and the component's bare bounding box cuts exactly those + // neighbours off. + const padMinX = minX > 0 ? minX - 1 : 0; + const padMinY = minY > 0 ? minY - 1 : 0; + const padMaxX = maxX < originalWidth - 1 ? maxX + 1 : maxX; + const padMaxY = maxY < originalHeight - 1 ? maxY + 1 : maxY; + + const subWidth = padMaxX - padMinX + 1; + const subHeight = padMaxY - padMinY + 1; const inComponent = new Uint8Array(subWidth * subHeight); for (const flat of component) { const cx = flat % originalWidth; const cy = (flat - cx) / originalWidth; - inComponent[(cy - minY) * subWidth + (cx - minX)] = 1; + inComponent[(cy - padMinY) * subWidth + (cx - padMinX)] = 1; } const subGridTiles: Tile[][] = []; @@ -129,16 +140,15 @@ export function splitGridIntoIslands( for (let subY = 0; subY < subHeight; subY++) { const row: Tile[] = []; - const origY = minY + subY; + const origY = padMinY + subY; for (let subX = 0; subX < subWidth; subX++) { - const origX = minX + subX; + const origX = padMinX + subX; const localFlat = subY * subWidth + subX; originalTileIndices[localFlat] = origY * originalWidth + origX; - row.push({ - x: subX, - y: subY, - type: inComponent[localFlat] ? "grass" : "water", - }); + // The board's own terrain, not a "mine / not mine" flag — that is + // what `inComponent` is, and it is carried separately because a + // neighbouring island's grass is real grass and still not ours. + row.push({ x: subX, y: subY, type: grid[origY][origX].type }); } subGridTiles.push(row); } @@ -147,6 +157,8 @@ export function splitGridIntoIslands( width: subWidth, height: subHeight, grid: subGridTiles, + buildable: inComponent, + tileCount: component.length, originalTileIndices, }); } @@ -183,7 +195,7 @@ function estimateIslandMaxPower( island: IslandSubGrid, roster: EffectiveBuilding[], ): number { - const buildableTiles = countGrassTiles(island.grid); + const buildableTiles = island.tileCount; if (buildableTiles <= 0) return 0.0; let cVal = 0.0; diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index 42b4672..3900dbf 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -11,6 +11,7 @@ import { AlternateCollector, isDistinctLayout, powerTies } from "./alternates"; import { Rng, randomSeed } from "./rng"; import { simulateIsland, type SimPlacedBuilding } from "./simulate"; import type { + AnomalyDefinition, EffectiveBuilding, IslandLayout, IslandSubGrid, @@ -1751,7 +1752,9 @@ export async function replayIslandDeterministic( rngSeed: number, maxSteps: number, ): Promise { - const ctx = buildIslandContext(island.grid); + // No anomaly, and never one: the fixtures are a determinism harness for the + // search itself, and a rule change is a different question asked of it. + const ctx = buildIslandContext(island.grid, island.buildable); if (ctx.n === 0) return { placements: [], powerOutput: 0.0 }; const reactors = effectiveBuildings @@ -1812,8 +1815,9 @@ export async function solveIsland( timeBudgetS: number, hooks?: SearchHooks, rngSeed?: number, + anomaly?: AnomalyDefinition, ): Promise { - const ctx = buildIslandContext(island.grid); + const ctx = buildIslandContext(island.grid, island.buildable, anomaly); if (ctx.n === 0) return { placements: [], powerOutput: 0.0 }; const reactors = effectiveBuildings diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts index 71b35fa..828e181 100755 --- a/packages/solver/src/solver/simulate.ts +++ b/packages/solver/src/solver/simulate.ts @@ -245,7 +245,9 @@ function row( x: ctx.xs[t], y: ctx.ys[t], buildingId: b.id, - baseValue: b.effectiveValue, + // The authored tier value, never the scaled one — a placement's tier is + // resolved back out of this. See `EffectiveBuilding.baseValue`. + baseValue: b.baseValue, powerGenerated, heatProduced, heatConsumed, diff --git a/packages/solver/src/solver/solver.ts b/packages/solver/src/solver/solver.ts index 482b880..145ffd2 100755 --- a/packages/solver/src/solver/solver.ts +++ b/packages/solver/src/solver/solver.ts @@ -5,10 +5,13 @@ import { splitGridIntoIslands, } from "./island"; import { solveIsland, type IslandSolution } from "./placementSearch"; +import { getAnomaly } from "../data/anomalies"; import { getEffectiveBuildings } from "../data/effectiveBuildings"; import type { + AnomalyDefinition, BuildingDefinition, EffectiveBuilding, + PrestigeScales, IslandSubGrid, OptimizationResult, PlacedBuilding, @@ -31,6 +34,12 @@ export const DEFAULT_TIME_BUDGET_S = 10.0; export interface IslandPlan { islands: IslandSubGrid[]; effectiveBuildings: EffectiveBuilding[]; + /** + * The rules every island is solved under. Resolved here so both solve paths + * and the worker pool hand the same object down, and never `undefined` — + * "no anomaly" is one of them. + */ + anomaly: AnomalyDefinition; /** Per-island time budget, proportional to buildable-tile count. */ budgetsS: number[]; /** Per-island RNG seed, or undefined for a fresh stochastic stream. */ @@ -54,6 +63,8 @@ export function planSolve( unlockedUpgrades: Record, timeBudgetS: number, rngSeed?: number, + anomalyId?: string, + prestige?: PrestigeScales, ): IslandPlan | null { if (!grid || grid.length === 0 || !grid[0] || grid[0].length === 0) return null; @@ -61,7 +72,13 @@ export function planSolve( const totalGrassTiles = countGrassTiles(grid); if (totalGrassTiles === 0) return null; - const effectiveBuildings = getEffectiveBuildings(buildings, unlockedUpgrades); + // Research folds in here, at the one place the roster is resolved, so every + // island and every attempt is handed the same already-scaled buildings. + const effectiveBuildings = getEffectiveBuildings( + buildings, + unlockedUpgrades, + prestige, + ); if (effectiveBuildings.length === 0) return null; const islands = splitGridIntoIslands( @@ -70,12 +87,17 @@ export function planSolve( ); if (islands.length === 0) return null; - const grassCounts = islands.map((island) => countGrassTiles(island.grid)); + const grassCounts = islands.map((island) => island.tileCount); const totalIslandGrass = grassCounts.reduce((a, b) => a + b, 0) || 1; return { islands, effectiveBuildings, + // Taken by id rather than as a definition, because that is also the + // worker protocol's form: an id is a string that survives any + // boundary, and `getAnomaly` is total, so one from a newer save + // resolves to the base rules instead of arriving half understood. + anomaly: getAnomaly(anomalyId), budgetsS: grassCounts.map( (count) => timeBudgetS * (count / totalIslandGrass), ), @@ -191,6 +213,8 @@ export async function solve( unlockedUpgrades, timeBudgetS, rngSeed, + options?.anomalyId, + options?.prestige, ); if (!plan) return createEmptyResult(grid?.[0]?.length ? countGrassTiles(grid) : 0); @@ -214,6 +238,7 @@ export async function solve( }, }, plan.seeds[i], + plan.anomaly, ); options?.onProgress?.(buildOptimizationResult(plan, islandResults)); } diff --git a/packages/solver/src/solver/types.ts b/packages/solver/src/solver/types.ts index 13cecbd..0c9d6f9 100755 --- a/packages/solver/src/solver/types.ts +++ b/packages/solver/src/solver/types.ts @@ -126,12 +126,195 @@ export interface EffectiveBuilding { effectiveValue: number; energy: number; waste: number; + /** + * The **authored** tier value this was resolved from — `effectiveValue` + * before any multiplier was applied to it. + * + * It exists because a multiplier breaks the identity `effectiveValue` used to + * carry. A placement records the value it was placed at and `effectiveAtValue` + * resolves the tier back out of it by matching the catalogue, so reporting a + * *scaled* value there matches the wrong tier and then scales it a second + * time: a generator at authored 320 under a x2 research reports 640, matches + * the authored 640 tier, and comes back as 1280 at one level too high. + * + * So `simulateIsland` reports this, never `effectiveValue`, and every scaling + * helper must carry it through untouched. + */ + baseValue: number; +} + +// ============================================================================ +// PRESTIGE (TIME LAB) +// ============================================================================ + +/** + * A Time Lab research that changes what buildings are worth. + * + * Only the ones the solver can feel are modelled — see `PRESTIGE_UPGRADES` for + * the seven that are not and why. Every one of these is a **uniform multiplier + * on the roles it names**, which is the same shape the stat anomalies turned + * out to have, so both go through `scaleEffectiveBuilding`. + * + * `bonuses` is the game's authored `BonusPercentage` per level, as a fraction: + * `1` is +100%. The **bonus is the authored figure and the multiplier is + * derived** (`1 + bonus`), which is the direction the game itself works in and + * the direction its UI reads — it shows "+100%", never "×2". Carrying both + * would be two spellings of one number to keep in step. + * + * Indexed like `BuildingDefinition.levels`: `bonuses[0]` is the first level, + * which the UI numbers 1. An upgrade absent from the player's record is not + * researched at all and multiplies by 1, so there is no zero entry. + */ +export interface PrestigeUpgrade { + id: string; + name: string; + /** The game's own label for what it changes, e.g. "Cooling Output". */ + effect: string; + description: string; + /** Which roles it scales. */ + roles: readonly BuildingType[]; + bonuses: readonly number[]; +} + +/** + * The resolved factor for each role, with every researched upgrade folded in. + * + * A plain record rather than a list of upgrades because that is all the roster + * resolution needs, and it keeps `getEffectiveBuildings` from having to know + * what a Time Lab is. An unresearched roster is every role at 1. + */ +export type PrestigeScales = Readonly>; + +// ============================================================================ +// ANOMALIES +// ============================================================================ + +/** + * Which anomaly a timeline is running under. `"none"` is a real choice rather + * than a missing value: it means the base rules of `docs/game-logic.md`, and + * spelling it out keeps every consumer branching on one field instead of on + * `undefined`. + */ +export type AnomalyId = + | "none" + | "cryo_nexus" + | "tidal_ascendancy" + | "singularity_isolation"; + +/** + * Identity and the game's own wording, shared by every anomaly. + * + * The three strings are the game's own, with its `{0}` placeholders resolved + * from the same `values` the rule fields carry — so the text and the numbers + * the solver runs on cannot drift. `benefit` and `drawback` are the one-line + * pair the game's own card shows; `description` is the full rule. + */ +export interface AnomalyBase { + id: AnomalyId; + name: string; + /** One line on what it gives you. */ + benefit: string; + /** One line on what it costs. */ + drawback: string; + /** The full rule, near enough verbatim. */ + description: string; } +/** The base rules, unmodified. */ +export interface BaselineAnomaly extends AnomalyBase { + rule: "baseline"; + id: "none"; +} + +/** + * Cryo Nexus: every cooler on the island pays into one pool, taxed on the way + * in, and the pool reaches every producer regardless of adjacency. + * + * This replaces the cooling distribution rather than scaling anything, which is + * why it carries no multiplier on `EffectiveBuilding` — `contribution` is a tax + * on what each cooler pays *into the pool*, not a re-rating of the cooler. + */ +export interface SharedCoolingAnomaly extends AnomalyBase { + rule: "shared_cooling"; + /** Fraction of its authored cooling each cooler contributes. */ + contribution: number; +} + +/** + * Tidal Ascendancy: a building Chebyshev-adjacent to one of `terrain` is scaled. + * + * Decided by terrain alone, so the multiplier is a property of the *tile* and + * is fixed for the whole solve — the first rule in the game where a + * non-buildable tile does anything at all. + */ +export interface TerrainAffinityAnomaly extends AnomalyBase { + rule: "terrain_affinity"; + /** Tile types that grant the bonus to a building standing beside one. */ + terrain: readonly TileType[]; + multiplier: number; +} + +/** + * Singularity Isolation: a building of `role` is scaled by `isolated` when no + * other building of that role is Chebyshev-adjacent, and by `crowded` when one + * or more is. + * + * The only rule so far whose multiplier depends on the layout rather than on + * the board, so it cannot be folded into the roster or precomputed per tile. + * The test is two-way on purpose: one neighbour costs exactly what five do. + */ +export interface RoleIsolationAnomaly extends AnomalyBase { + rule: "role_isolation"; + role: BuildingType; + isolated: number; + crowded: number; +} + +/** + * One anomaly, as a tagged union over the *rule shape* rather than over the + * anomaly's identity. + * + * `id` names which anomaly it is and `rule` says what kind of thing it does, so + * a fourth anomaly that reuses an existing shape is a table entry and nothing + * more, while a genuinely new rule is a new variant the solver fails to compile + * without handling. Both halves of that were wanted: the game ships these in + * batches, and most of a batch is a re-parameterised rule. + * + * Every variant but `shared_cooling` resolves to a **uniform** scale on a + * building's three figures — see `scaleEffectiveBuilding`. + */ +export type AnomalyDefinition = + | BaselineAnomaly + | SharedCoolingAnomaly + | TerrainAffinityAnomaly + | RoleIsolationAnomaly; + +/** + * One island, as a window onto the original board. + * + * The window is the component's bounding box **padded by one tile** on every + * side (clamped to the board), and `grid` carries each tile's real terrain + * rather than a flattened "not mine" marker. Both exist for the same reason: a + * rule can care what a building is standing next to, and a rock is not a lake. + * The padding is what makes that answerable for a building on the island's own + * edge, whose neighbours lie outside the component's box. + * + * Real terrain means a *neighbouring* island's grass can fall inside the + * window, so which tiles are this island's is `buildable`, never `type === + * "grass"`. + */ export interface IslandSubGrid { width: number; height: number; grid: Tile[][]; + /** + * 1 where the tile belongs to this island, 0 otherwise — including grass that + * belongs to a different island. Indexed `subY * width + subX`, same as + * `originalTileIndices`. + */ + buildable: Uint8Array; + /** How many tiles this island has. `buildable`'s popcount, kept rather than recounted. */ + tileCount: number; /** * 1D Int32Array mapping each sub-grid tile (at index `subY * width + subX`) * to its flat index in the original full grid (`origY * origWidth + origX`). @@ -198,6 +381,25 @@ export interface SolveOptions { onProgress?: (result: OptimizationResult) => void; shouldStop?: () => boolean; reportIntervalMs?: number; + /** + * The timeline's anomaly, by id. Omitted means the base rules. + * + * Accepted but **not yet honoured** — the rules are modelled and threaded, + * not implemented; see `docs/game-logic.md`. Time Lab research is a different + * matter and already lands, because it resolves into the roster before a + * solve ever starts. + */ + anomalyId?: AnomalyId; + /** + * Time Lab research, already folded to one factor per role. + * + * Unlike the anomaly this **is** honoured, because it needs nothing from the + * search: it resolves into the roster here and the `EffectiveBuilding[]` that + * crosses the worker boundary already carries it in its three numbers. That + * is also why the worker protocol below has no field for it — sending it + * again would be sending it twice. + */ + prestige?: PrestigeScales; } // ============================================================================ @@ -212,6 +414,8 @@ export type IslandWorkerRequest = effectiveBuildings: EffectiveBuilding[]; timeBudgetS: number; reportIntervalMs?: number; + /** The timeline's anomaly, by id; omitted means the base rules. */ + anomalyId?: AnomalyId; /** Pins the annealing walk's random stream; omitted means a fresh seed. */ rngSeed?: number; } diff --git a/packages/solver/tests/alternates.test.ts b/packages/solver/tests/alternates.test.ts index 74ad284..7dce5a4 100755 --- a/packages/solver/tests/alternates.test.ts +++ b/packages/solver/tests/alternates.test.ts @@ -40,7 +40,14 @@ function grassGrid(w: number, h: number): Tile[][] { /** A stand-in building; only its id reaches the layout key. */ function stub(id: string): EffectiveBuilding { - return { id, type: "cooler", effectiveValue: 1, energy: 0, waste: 0 }; + return { + id, + type: "cooler", + effectiveValue: 1, + energy: 0, + waste: 0, + baseValue: 1, + }; } /** `..A.B` — dots are empty tiles, letters are building ids. */ diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts new file mode 100644 index 0000000..145d159 --- /dev/null +++ b/packages/solver/tests/anomalies.test.ts @@ -0,0 +1,104 @@ +/** + * The anomaly catalogue and the one primitive every stat anomaly goes through. + * + * Two things here can fail silently rather than loudly. `getAnomaly` is the + * only resolution path and it takes a bare string off `localStorage` and off + * the worker boundary, so an id this build has never heard of must land on the + * base rules rather than on `undefined` — the app would otherwise solve a board + * with no rules at all. And `scaleEffectiveBuilding` is what makes a bonus mean + * the same thing for all four roles: scale only the figure a role "produces" + * and a generator's bonus becomes free power, because its cooling appetite + * would not have grown with it. + */ +import { describe, expect, it } from "vitest"; +import { + ANOMALIES, + DEFAULT_ANOMALY_ID, + getAnomaly, +} from "../src/data/anomalies"; +import { scaleEffectiveBuilding } from "../src/data/effectiveBuildings"; +import { wasteIsCovered } from "../src/solver/physics"; +import type { EffectiveBuilding } from "../src/solver/types"; + +const generator: EffectiveBuilding = { + id: "gen", + type: "generator", + effectiveValue: 400, + energy: 300, + waste: 100, + baseValue: 400, +}; + +describe("the anomaly catalogue", () => { + it("has a unique id per entry", () => { + const ids = ANOMALIES.map((a) => a.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("resolves every id it ships", () => { + for (const anomaly of ANOMALIES) { + expect(getAnomaly(anomaly.id)).toBe(anomaly); + } + }); + + it("falls back to the base rules for an id it does not know", () => { + // A save from a later build, most likely. Running the base rules is the + // honest answer, and it is what the unknown id would have meant anyway. + for (const id of ["cascade_flux", "", undefined]) { + expect(getAnomaly(id).rule).toBe("baseline"); + } + }); + + it("defaults to no anomaly", () => { + expect(getAnomaly(DEFAULT_ANOMALY_ID).rule).toBe("baseline"); + }); +}); + +describe("scaling a resolved building", () => { + it("scales all three figures by the same factor", () => { + expect(scaleEffectiveBuilding(generator, 2.5)).toEqual({ + id: "gen", + type: "generator", + effectiveValue: 1000, + energy: 750, + waste: 250, + // Untouched — see below. + baseValue: 400, + }); + }); + + it("leaves the authored tier value alone", () => { + // The one field that must NOT scale. It is what a placement records and + // what `effectiveAtValue` resolves the tier back out of, so scaling it + // matches a higher tier and then applies the factor a second time: an + // authored 400 under x2 would report 800, match the authored 800 tier, and + // come back 1600 at one level too high. + const scaled = scaleEffectiveBuilding(generator, 2); + expect(scaled.baseValue).toBe(generator.baseValue); + expect(scaled.effectiveValue).toBe(800); + }); + + it("is identity at a factor of one", () => { + // By reference: the unscaled case is most of every board and must allocate + // nothing. + expect(scaleEffectiveBuilding(generator, 1)).toBe(generator); + }); + + it("leaves a bonus needing proportionally more cooling", () => { + // The point of scaling uniformly. Cooling that exactly covered the base + // building must not still cover the bonused one, or a multiplier would be + // free power rather than a bigger bet. + const scaled = scaleEffectiveBuilding(generator, 1.67); + expect(wasteIsCovered(generator.waste, generator.waste)).toBe(true); + expect(wasteIsCovered(scaled.waste, generator.waste)).toBe(false); + expect(wasteIsCovered(scaled.waste, scaled.waste)).toBe(true); + }); + + it("keeps a penalised building's figures in the same proportion", () => { + const scaled = scaleEffectiveBuilding(generator, 0.8); + expect(scaled.energy / scaled.effectiveValue).toBeCloseTo( + generator.energy / generator.effectiveValue, + 12, + ); + }); +}); diff --git a/packages/solver/tests/blueprint.test.ts b/packages/solver/tests/blueprint.test.ts index 75c4880..8a72485 100755 --- a/packages/solver/tests/blueprint.test.ts +++ b/packages/solver/tests/blueprint.test.ts @@ -23,7 +23,10 @@ import { describe, expect, it } from "vitest"; import { BUILDINGS } from "../src/data/buildings"; import { makeGrid } from "../src/grid"; import { + BLUEPRINT_VERSION, + MIN_GRID_DIM, blueprintKey, + blueprintRules, decodeBlueprint, encodeBlueprint, type BlueprintPlacement, @@ -224,18 +227,19 @@ describe("tier table", () => { cooler1: 3, }); - // w h tiles n [byte, level] - expect(await payloadOf(code)).toEqual([2, 1, 10, 1, 1, 10, 3]); + // v w h tiles n [byte, level] + expect(await payloadOf(code)).toEqual([1, 2, 1, 10, 1, 1, 10, 3]); }); - it("leaves the payload untouched when no tiers are given", async () => { - // This is what let `blueprintKey` keep its meaning across the change: a - // code encoded without tiers is byte for byte what it always was. + it("appends nothing when no tiers are given", async () => { + // What lets `blueprintKey` keep its meaning: a code encoded without tiers + // is the header and the tiles and nothing else. const board = grid(3, 2); const placements = [place(1, 0, "cooler1")]; + // v w h tiles expect(await payloadOf(await encodeBlueprint(board, placements))).toEqual([ - 3, 2, 1, 10, 1, 1, 1, 1, + 1, 3, 2, 1, 10, 1, 1, 1, 1, ]); }); @@ -305,3 +309,174 @@ describe("tier table", () => { expect(decoded.placements).toEqual(placements); }); }); + +describe("the rules section", () => { + /* + * The anomaly and the research a board was built under. Everything here is a + * way the section fails *quietly*: a reader that mistakes "unstated" for + * "none" rates someone else's board at nothing and still shows a number, and + * one that starts reading at the wrong offset finds an anomaly where a tier + * was. Neither throws. + */ + const GRID = makeGrid(["GG", "GG"]); + + it("round-trips the anomaly and the research", async () => { + const code = await encodeBlueprint( + GRID, + [], + {}, + blueprintRules("cryo_nexus", { absolute_zero: 2, stellar_forge: 4 }), + ); + const decoded = await decodeBlueprint(code); + + expect(decoded.rules).toEqual({ + anomalyId: "cryo_nexus", + research: { absolute_zero: 2, stellar_forge: 4 }, + }); + }); + + it("distinguishes 'no rules stated' from 'no anomaly and no research'", async () => { + // The whole reason `rules` is nullable. A save states nothing and must pick + // up the reader's own; a share code can state emptiness and mean it. + const silent = await decodeBlueprint(await encodeBlueprint(GRID, [])); + expect(silent.rules).toBeNull(); + + const explicit = await decodeBlueprint( + await encodeBlueprint(GRID, [], undefined, blueprintRules("none", {})), + ); + expect(explicit.rules).toEqual({ anomalyId: "none", research: {} }); + }); + + it("writes a tier count byte ahead of rules given no tiers", async () => { + // The section is read only after the tier table, so rules without that byte + // would be read as a tier table. Zero there means "tiers unknown", which is + // what a code with no tiers has always meant. + const code = await encodeBlueprint( + GRID, + [], + undefined, + blueprintRules("tidal_ascendancy", {}), + ); + const payload = await payloadOf(code); + + expect(payload.slice(0, 3)).toEqual([1, 2, 2]); + // tiles, then the zero tier count, then the anomaly byte and a zero + // research count. + expect(payload.slice(7)).toEqual([0, 2, 0]); + + const decoded = await decodeBlueprint(code); + expect(decoded.tiers).toEqual({}); + expect(decoded.rules?.anomalyId).toBe("tidal_ascendancy"); + }); + + it("reads rules that sit after a real tier table", async () => { + const placements: BlueprintPlacement[] = [ + { x: 0, y: 0, buildingId: "cooler1" }, + ]; + const decoded = await decodeBlueprint( + await encodeBlueprint( + GRID, + placements, + { cooler1: 3 }, + blueprintRules("singularity_isolation", { infinite_grid: 1 }), + ), + ); + + expect(decoded.tiers).toEqual({ cooler1: 3 }); + expect(decoded.rules).toEqual({ + anomalyId: "singularity_isolation", + research: { infinite_grid: 1 }, + }); + }); + + it("reads an unknown anomaly byte as no anomaly", async () => { + // A code from a later build naming an anomaly this one has never heard of. + // The same fallback `getAnomaly` makes, and the only safe one. + const decoded = await decodeBlueprint( + codeFor([1, 2, 2, 1, 1, 1, 1, 0, 200, 0]), + ); + expect(decoded.rules?.anomalyId).toBe("none"); + }); + + it("drops a truncated rules section rather than the layout", async () => { + // A count that outruns the payload. The tiles are the part that cannot be + // reconstructed and they have already been read. + const decoded = await decodeBlueprint( + codeFor([1, 2, 2, 1, 1, 1, 1, 0, 1, 9]), + ); + expect(decoded.grid).toHaveLength(2); + expect(decoded.rules).toEqual({ anomalyId: "cryo_nexus", research: {} }); + }); + + it("keeps rules out of the layout key", async () => { + // `blueprintKey` answers "is this the same layout?" — the same reason tiers + // are not in it. Choosing an anomaly must not read as a repainted board. + const withRules = await encodeBlueprint( + GRID, + [], + {}, + blueprintRules("cryo_nexus", { absolute_zero: 4 }), + ); + expect(await payloadOf(withRules)).not.toEqual( + [...blueprintKey(GRID, [])].map((c) => c.charCodeAt(0)), + ); + expect(blueprintKey(GRID, [])).toBe(blueprintKey(GRID, [])); + }); +}); + +describe("the format version byte", () => { + /* + * A code written before the byte existed begins with its width, so the two + * are told apart by value: below `MIN_GRID_DIM` is a version, at or above it + * is a width. Nothing here throws when it goes wrong — a legacy code read as + * versioned is off by one byte from its second tile onward, which is a + * different board, not an error. That is what these pin. + */ + it("writes the current version as byte 0", async () => { + const payload = await payloadOf(await encodeBlueprint(grid(6, 1))); + expect(payload[0]).toBe(BLUEPRINT_VERSION); + expect(payload.slice(0, 3)).toEqual([BLUEPRINT_VERSION, 6, 1]); + }); + + it("still reads a code written before the byte existed", async () => { + // Byte 0 is 6 — too big to be a version, so this is a width and the tiles + // start at byte 2. Every share code and saved layout in the wild is one of + // these. + const decoded = await decodeBlueprint(codeFor([6, 1, 1, 1, 2, 1, 1, 1])); + + expect([decoded.width, decoded.height]).toEqual([6, 1]); + expect(decoded.grid[0].map((t) => t.type)).toEqual([ + "grass", + "grass", + "rock", + "grass", + "grass", + "grass", + ]); + expect(decoded.rules).toBeNull(); + }); + + it("reads a legacy code at the narrowest board that can exist", async () => { + // The boundary the whole scheme rests on: a width of exactly + // `MIN_GRID_DIM` must still read as a width, not as a version. + const tiles = new Array(MIN_GRID_DIM).fill(1); + const decoded = await decodeBlueprint(codeFor([MIN_GRID_DIM, 1, ...tiles])); + expect(decoded.width).toBe(MIN_GRID_DIM); + }); + + it("refuses a version it does not know rather than guessing", async () => { + // Reading a later payload as this one would not fail — the tiles are + // positional — so it would quietly hand back a different board. + await expect( + decodeBlueprint(codeFor([BLUEPRINT_VERSION + 1, 2, 2, 1, 1, 1, 1])), + ).rejects.toThrow(/format version/); + }); + + it("encodes boards smaller than the legacy floor", async () => { + // `MIN_GRID_DIM` constrains which *old* codes are recognisable, not what a + // board may be: a versioned code says its width where no value is + // ambiguous, which is what the 4x4 fixtures rely on. + const decoded = await decodeBlueprint(await encodeBlueprint(grid(2, 2))); + expect([decoded.width, decoded.height]).toEqual([2, 2]); + }); +}); diff --git a/packages/solver/tests/helpers.ts b/packages/solver/tests/helpers.ts index 9c1b922..7b68f78 100644 --- a/packages/solver/tests/helpers.ts +++ b/packages/solver/tests/helpers.ts @@ -28,7 +28,14 @@ import type { /** A pure heat source: heat out, no power and nothing to cool. */ export function reactor(value: number, id = "reactor"): EffectiveBuilding { - return { id, type: "reactor", effectiveValue: value, energy: 0, waste: 0 }; + return { + id, + type: "reactor", + effectiveValue: value, + energy: 0, + waste: 0, + baseValue: value, + }; } /** Converts reactor heat to power; `value` is its max heat input H_max. */ @@ -39,11 +46,19 @@ export function generator(value: number, id = "generator"): EffectiveBuilding { effectiveValue: value, energy: value * GENERATOR_ENERGY_RATIO, waste: value * GENERATOR_WASTE_RATIO, + baseValue: value, }; } export function cooler(value: number, id = "cooler"): EffectiveBuilding { - return { id, type: "cooler", effectiveValue: value, energy: 0, waste: 0 }; + return { + id, + type: "cooler", + effectiveValue: value, + energy: 0, + waste: 0, + baseValue: value, + }; } /** @@ -63,6 +78,7 @@ export function directProducer( effectiveValue: value, energy: value * (1 - wasteRatio), waste: value * wasteRatio, + baseValue: value, }; } diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index c7467ef..7f286a6 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -192,7 +192,16 @@ describe("coordinate remapping", () => { expect(island.grid[0][0].x).toBe(0); expect(island.grid[0][0].y).toBe(0); - expect([island.width, island.height]).toEqual([3, 2]); + // The window is the component's box (x 1-3, y 1-2) padded a tile on every + // side and clamped to the board, so it holds every neighbour of every + // island tile — which is what a terrain rule has to be able to read. + expect([island.width, island.height]).toEqual([5, 3]); + expect(island.tileCount).toBe(6); + // Padding brings in tiles that are not this island's, so membership is the + // mask rather than the terrain. + expect([...island.buildable]).toEqual([ + 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, + ]); }); }); @@ -237,6 +246,8 @@ describe("the theoretical max-power bound", () => { width: 0, height: 0, grid: [], + buildable: new Uint8Array(0), + tileCount: 0, originalTileIndices: [], }; diff --git a/packages/solver/tests/placementSearch.downgrade.test.ts b/packages/solver/tests/placementSearch.downgrade.test.ts index 88d1609..eb17ce1 100755 --- a/packages/solver/tests/placementSearch.downgrade.test.ts +++ b/packages/solver/tests/placementSearch.downgrade.test.ts @@ -31,7 +31,14 @@ import type { EffectiveBuilding, Placement, Tile } from "../src/solver/types"; // --- Building factories --------------------------------------------------- function reactor(value: number, id = "reactor"): EffectiveBuilding { - return { id, type: "reactor", effectiveValue: value, energy: 0, waste: 0 }; + return { + id, + type: "reactor", + effectiveValue: value, + energy: 0, + waste: 0, + baseValue: value, + }; } function generator(value: number, id = "generator"): EffectiveBuilding { @@ -41,11 +48,19 @@ function generator(value: number, id = "generator"): EffectiveBuilding { effectiveValue: value, energy: value * GENERATOR_ENERGY_RATIO, waste: value * GENERATOR_WASTE_RATIO, + baseValue: value, }; } function cooler(value: number, id = "cooler"): EffectiveBuilding { - return { id, type: "cooler", effectiveValue: value, energy: 0, waste: 0 }; + return { + id, + type: "cooler", + effectiveValue: value, + energy: 0, + waste: 0, + baseValue: value, + }; } function directProducer( @@ -59,6 +74,7 @@ function directProducer( effectiveValue: value, energy: value * (1 - wasteRatio), waste: value * wasteRatio, + baseValue: value, }; } @@ -184,6 +200,7 @@ describe("downgradeOversized", () => { effectiveValue: 1000, energy: 750, waste: 400, + baseValue: 1000, }; const genS: EffectiveBuilding = { id: "generator_s", @@ -191,6 +208,7 @@ describe("downgradeOversized", () => { effectiveValue: 100, energy: 75, waste: 25, + baseValue: 100, }; const roster = [ reactor(100), diff --git a/packages/solver/tests/placementSearch.test.ts b/packages/solver/tests/placementSearch.test.ts index 2fa06b1..fb5fde1 100644 --- a/packages/solver/tests/placementSearch.test.ts +++ b/packages/solver/tests/placementSearch.test.ts @@ -106,10 +106,10 @@ describe("the solveIsland contract", () => { // Whatever the search decides, the result must be structurally valid. it("returns nothing for an island with no buildable tiles", async () => { + // Which tiles are this island's is the mask, not the terrain — an island + // window carries the board's real terrain and a neighbour's grass with it. const island = islandFrom(["GGG"]); - for (const row of island.grid) { - for (const tile of row) tile.type = "water"; - } + island.buildable.fill(0); const { placements, powerOutput } = await solveIsland( island, diff --git a/packages/solver/tests/prestige.test.ts b/packages/solver/tests/prestige.test.ts new file mode 100644 index 0000000..93902f8 --- /dev/null +++ b/packages/solver/tests/prestige.test.ts @@ -0,0 +1,247 @@ +/** + * Time Lab research, and the one thing about it that can fail quietly. + * + * These are plain multipliers, so an arithmetic slip shows up as a board that + * is simply worth more or less than it should be — no crash, no wrong-looking + * layout, just every figure off by a factor. Two things could cause that and + * neither would announce itself: the **presence rule** (a key's presence is + * what "researched" means, exactly as in `buildingUpgrades`, so an upgrade at + * index 0 is researched at its *first* level, not absent), and the derivation + * of the factor from the game's authored bonus. + */ +import { describe, expect, it } from "vitest"; +import { + PRESTIGE_UPGRADES, + prestigeMultiplier, + prestigeScales, +} from "../src/data/prestige"; +import { + effectiveAtValue, + getEffectiveBuildings, +} from "../src/data/effectiveBuildings"; +import { planSolve } from "../src/solver/solver"; +import { makeGrid } from "../src/grid"; +import type { BuildingDefinition } from "../src/solver/types"; + +const DEFS: readonly BuildingDefinition[] = [ + { + id: "c", + name: "C", + price: 0, + displayIndex: 0, + type: "cooler", + levels: [{ cooling: 100 }], + }, + { + id: "r", + name: "R", + price: 0, + displayIndex: 1, + type: "reactor", + levels: [{ heat: 200 }], + }, + { + id: "g", + name: "G", + price: 0, + displayIndex: 2, + type: "generator", + levels: [{ heat: 400, energy: 300, waste: 100 }], + }, +]; +const ALL = { c: 0, r: 0, g: 0 }; + +describe("resolving research levels", () => { + it("is the identity when nothing is researched", () => { + // By reference: every roster resolution asks for this, and the unresearched + // case is the common one. + expect(prestigeScales({})).toBe(prestigeScales(undefined)); + expect(prestigeScales(undefined).cooler).toBe(1); + }); + + it("treats index 0 as researched at the first level, not as absent", () => { + // The presence rule, and the one that would silently under-rate a board: + // `buildingUpgrades` spells "unlocked at tier 1" as `{id: 0}`, and this + // record follows it. + const first = PRESTIGE_UPGRADES[0]; + expect(prestigeScales({ [first.id]: 0 })[first.roles[0]]).toBe( + prestigeMultiplier(first.bonuses[0]), + ); + expect(prestigeScales({})[first.roles[0]]).toBe(1); + }); + + it("reads the top level from the end of the table", () => { + const first = PRESTIGE_UPGRADES[0]; + const top = first.bonuses.length - 1; + expect(prestigeScales({ [first.id]: top })[first.roles[0]]).toBe( + prestigeMultiplier(first.bonuses[top]), + ); + }); + + it("clamps an index past the table rather than reading past its end", () => { + // The index arrives from `localStorage`, so a later build's save can name a + // sixth level this one does not have. + const first = PRESTIGE_UPGRADES[0]; + expect(prestigeScales({ [first.id]: 99 })[first.roles[0]]).toBe( + prestigeMultiplier(first.bonuses[first.bonuses.length - 1]), + ); + }); + + it("derives the factor as 1 + the game's authored bonus", () => { + // The game prints "+100%" and means ×2. Getting this backwards would halve + // or double every board without anything looking wrong. + expect(prestigeMultiplier(1.0)).toBe(2); + expect(prestigeMultiplier(2.25)).toBe(3.25); + }); + + it("leaves roles no researched upgrade names untouched", () => { + const scales = prestigeScales({ absolute_zero: 4 }); + expect(scales.cooler).toBeGreaterThan(1); + expect([scales.reactor, scales.generator, scales.direct_producer]).toEqual([ + 1, 1, 1, + ]); + }); +}); + +describe("research reaching the roster", () => { + it("scales a cooler's cooling and nothing else's", () => { + const roster = getEffectiveBuildings( + DEFS, + ALL, + prestigeScales({ absolute_zero: 0 }), + ); + const by = (id: string) => roster.find((b) => b.id === id)!; + expect(by("c").effectiveValue).toBe(100 * 2); + expect(by("r").effectiveValue).toBe(200); + expect(by("g").effectiveValue).toBe(400); + }); + + it("scales a generator uniformly, so its waste grows with its output", () => { + // The point of the shape. `infinite_grid` raises the overheat threshold as + // well as the energy, so a researched generator needs proportionally more + // cooling — it is a bigger bet, not free power. + const roster = getEffectiveBuildings( + DEFS, + ALL, + prestigeScales({ infinite_grid: 0 }), + ); + const g = roster.find((b) => b.id === "g")!; + expect([g.effectiveValue, g.energy, g.waste]).toEqual([800, 600, 200]); + }); + + it("scales a reactor's heat for Stellar Forge", () => { + const roster = getEffectiveBuildings( + DEFS, + ALL, + prestigeScales({ stellar_forge: 4 }), + ); + expect(roster.find((b) => b.id === "r")!.effectiveValue).toBe(200 * 5); + }); + + it("changes nothing when passed no research", () => { + const roster = getEffectiveBuildings(DEFS, ALL); + expect(roster.map((b) => b.effectiveValue)).toEqual([100, 200, 400]); + }); +}); + +describe("research reaching a solve", () => { + /* + * The gap this closes: a run hands the solver `buildings` and + * `unlockedUpgrades` and lets `planSolve` resolve the roster, so research + * applied anywhere *else* — the board readout, the run estimate — never + * reached the search at all. The symptom was invisible: a perfectly valid + * layout, optimised for a roster the player does not have. + */ + const GRID = makeGrid(["GGGGG", "GGGGG", "GGGGG"]); + const DEFS_ARR = DEFS as BuildingDefinition[]; + + it("resolves the plan's roster under the research it is given", () => { + const plan = planSolve( + GRID, + DEFS_ARR, + ALL, + 1, + undefined, + undefined, + prestigeScales({ absolute_zero: 0 }), + ); + const cooler = plan!.effectiveBuildings.find((b) => b.id === "c")!; + expect(cooler.effectiveValue).toBe(200); + }); + + it("resolves it unresearched when given none", () => { + const plan = planSolve(GRID, DEFS_ARR, ALL, 1); + expect( + plan!.effectiveBuildings.find((b) => b.id === "c")!.effectiveValue, + ).toBe(100); + }); + + it("raises the bound research raises", () => { + // The upper bound is computed from the same roster, so if research reached + // one and not the other the efficiency figure would read past 100%. + const plain = planSolve(GRID, DEFS_ARR, ALL, 1)!; + const boosted = planSolve( + GRID, + DEFS_ARR, + ALL, + 1, + undefined, + undefined, + prestigeScales({ infinite_grid: 4 }), + )!; + expect(boosted.theoreticalMaxPower).toBeGreaterThan( + plain.theoreticalMaxPower, + ); + }); + + it("carries the anomaly it is given, defaulting to none", () => { + expect(planSolve(GRID, DEFS_ARR, ALL, 1)!.anomaly.rule).toBe("baseline"); + expect( + planSolve(GRID, DEFS_ARR, ALL, 1, undefined, "cryo_nexus")!.anomaly.id, + ).toBe("cryo_nexus"); + // Total, like `getAnomaly`: an id from a newer save is the base rules. + expect( + planSolve(GRID, DEFS_ARR, ALL, 1, undefined, "not_a_thing")!.anomaly.rule, + ).toBe("baseline"); + }); +}); + +describe("what a solved placement reports under research", () => { + /* + * The round trip that breaks if a scaled value is reported as the placement's + * base value. `effectiveAtValue` resolves a tier by *matching* that number + * against the catalogue, so a scaled one matches a higher tier and is then + * scaled again — a valid-looking board rated one level too high, with no + * error anywhere. + */ + it("reports the authored tier value, not the scaled one", () => { + const scales = prestigeScales({ absolute_zero: 0 }); // level 1, x2 + const roster = getEffectiveBuildings( + DEFS as BuildingDefinition[], + ALL, + scales, + ); + const cooler = roster.find((b) => b.id === "c")!; + + expect(cooler.effectiveValue).toBe(200); // what the search works with + expect(cooler.baseValue).toBe(100); // what a placement records + }); + + it("round-trips a scored placement back to the same rating", () => { + const scales = prestigeScales({ infinite_grid: 0 }); // level 1, x2 + const roster = getEffectiveBuildings( + DEFS as BuildingDefinition[], + ALL, + scales, + ); + const gen = roster.find((b) => b.id === "g")!; + const def = (DEFS as BuildingDefinition[]).find((d) => d.id === "g")!; + + // `simulateIsland` writes `baseValue` into every row it reports; resolving + // that row again must give back the building the search actually used. + const resolved = effectiveAtValue(def, gen.baseValue, scales); + expect(resolved.effectiveValue).toBe(gen.effectiveValue); + expect(resolved.energy).toBe(gen.energy); + expect(resolved.waste).toBe(gen.waste); + }); +}); diff --git a/packages/solver/tests/rosterPower.test.ts b/packages/solver/tests/rosterPower.test.ts index 5af48c8..dbd964d 100644 --- a/packages/solver/tests/rosterPower.test.ts +++ b/packages/solver/tests/rosterPower.test.ts @@ -17,6 +17,7 @@ const cooler: EffectiveBuilding = { effectiveValue: 100, energy: 0, waste: 0, + baseValue: 100, }; const reactor: EffectiveBuilding = { id: "reactor1", @@ -24,6 +25,7 @@ const reactor: EffectiveBuilding = { effectiveValue: 100, energy: 0, waste: 0, + baseValue: 100, }; const generator: EffectiveBuilding = { id: "generator1", @@ -31,6 +33,7 @@ const generator: EffectiveBuilding = { effectiveValue: 100, energy: 75, waste: 25, + baseValue: 100, }; const turbine: EffectiveBuilding = { id: "windturbine1", @@ -38,6 +41,7 @@ const turbine: EffectiveBuilding = { effectiveValue: 10, energy: 7, waste: 3, + baseValue: 10, }; describe("rosterCanProducePower", () => { diff --git a/packages/solver/tests/simulate.test.ts b/packages/solver/tests/simulate.test.ts index cc73383..8dd068b 100644 --- a/packages/solver/tests/simulate.test.ts +++ b/packages/solver/tests/simulate.test.ts @@ -136,6 +136,7 @@ describe("generator physics", () => { effectiveValue: 8.85e21, energy: 6.64e21, waste: snapToAuthoredPrecision(8.85e21 - 6.64e21), + baseValue: 8.85e21, }; const { power, byPos } = run(["RGC"], { @@ -159,6 +160,7 @@ describe("generator physics", () => { effectiveValue: 100, energy: 70, waste: 30, + baseValue: 100, }; const { power, byPos } = run(["RGC"], { diff --git a/public/icons/anomaly_cryo_nexus.webp b/public/icons/anomaly_cryo_nexus.webp new file mode 100644 index 0000000000000000000000000000000000000000..b63d1cc16a9c5cd53f0f1f9b37942a5c71d8901c GIT binary patch literal 16392 zcmV+jK=;2=Nk&EhKmY(&MM6+kP&iEVKL7wPf50CQ4M>P2$&n<>sJdAZ_51<1_=OPB z{|U&wYkuuM(VrNjcKevb|EV%n;>Yf6xvEl8X*DJY3A?H)*Y0k05|!k+z#02ZcR!j@ zXu6lO+(K?uSt!>M@SwYp0Az^}S>8(k0=ihEWUE?H8Pplv<`AvrpQ6z|-WH>eFC8GS1%d{iHT zdp@o>5P>B|HSwBl5eb2QE{1IY^-WX^oW@~wiHBIbcv#`7h=e`pk=P`KO5&A;gFHaJQ430i|Wz-qz8DG3MjYUW_=T$H~-X{r}5Z^_leg zVN!$<;>8H_VT7QzZQI(mxy(>f(vHdbH0H1S^UTb==SUS<`09x)y?$i9w);N;aKiuu zVg>xbAQJ!pDhYNn<>N4`*aZbZzAca>=;jsx(}~;ZwzXT#d>o;Z8q!a#(Z;UyoGGsJKRWOM$>xN3Gbwpk^Kl_f|^>`e{C#MmhZLQmu6Yb2m6i}v2>EPNx71A|| z2K`k_3yi%Rk0ZU|6*j7rHcoLaE2VsRr<%{ZD2)$*exS0{>bR_f%@}9jOW_LQi2;JuJ2RNnRt$3v3_ixZVspSfXlD#w@|#| zd$BrL@2v?S(^yy9ZzpHwSS;s8P#pP*{fPvcK|1vi)pX`|e^+@|dY9aV0@&>pUz0kB zM4luvwtoMleZC%l;@CFlHk_M*!+s984b9Nlq6PI}9%j8A{9}DKKmZyAo!NSwmm&}Q zIg&&=o<8yP&z>gMV)5T%AMRgr8@COO=|h_i^Xaf)s?lSBK+67<6;NBIe~P7TO!}U_ z@BW%Dv@XdyOxJ;Cx7V!SZ;$`4H?C+i?1`UV+_dt?!XKJhT5VU&piq1z6Kh zl4#XqR?X^8yNcmpJsj-AU>jjwCc~n%r2~MWei}6q4Q=s|0PrIPo!WLGok|i}1^m`m z>+8&?AiZ442n#=QvfdLo- zB)Vf4;#5tiQz)8i!!Im$JQHAtImh)N8U6T6;yk*!GXoqcq;F=i_);E{rv+ zZuNSn()r0*7y|$m&3~b2(&PqGk&1_l1iaIF)%Vu#FoJPl)h?&gvqv_@4e;$VwEh%zVLS~}BOfe3 z)7M3p@~I^CDG{b%2LPau3Is_Xk7ug1iO0na42+qyNz3t$^jwFS6#ISbE46Kkv?bG- zFxvsc06-t#2ZE#r0gIRy&q+&hD>L|D(nh9 zv0!jVhpfN24CzccQ*O#)C4?}9jv?5E`xCPjQw8oG6dm{Sl!P3qkSPR#%c^?StWUBf z4=;&J|M*TB+p^OC{0{ly^ZH2_>P=rlEk*r99CcaWb-EJSe9i&}wbKoQtM}-C^B>6K zLYjXfTx_eb+m3%kCU3be*ik~Ai$>$)Iz()AjGNBI&<_`DuUL^Ni#U zkn1PXqyO5)>mT*$gU1bUbAbXhR1N>M^korM!uPk&vKj;sy25{FqG%F3kegktUR7$^ z@C{eMY4$}yB0=CgVi<-3UrcUJ1Vv*3B z?21mEs{B%=;;~~Nbk$qf8_|#UWg~oWe20ptVt;5#6Z>P&{Yz|{4}Pp|Tbt3gt^1Sg z&dgD7Jx2EeP$7EBdR2M>dNF#$%uGL6dV}|qEJM%l&dkh=5z|1}k!-70dYy;6^ZO(P z0wEgWc0K3ZEjN;F)pmNFbMAu=F8KdTN<#h*S5cwGbaBhW|No4e@9lCb^K`Xs+qP}H zf6caS+pdi(r@Cn$T{U|}wx+w>nzR$(`~Uy7ZTtUwJS9_{It;@XGcz+YGcz;4UT4gF z#EfUm%xT9^=G39ai5;>GlKeb)KIQaJxE0fZmyS2Tn3-SWtrt7^|DTg2y}ti{aSBsw z+X@x!W}DfYGg8RV-aY#+Q)J&|wr#XgJ=LZD)&KwVWZSkXNw#g(KIamXB_hg(#T+*? zGlh1@V>`Ha26l)kw#&}PP5@IDJhYja84ZfdRsmH}5z#d%Z`*c!ZqAu6$Jn+Pu?Dei zJJrhFr|TYF^Ix!So3ZV&^;3WOCII*T|BL0e`3=vUIk;!fqIRW-3`x!OJTZkiWoE`Z z?iF75w&@)+a~mue3T9L)Su1I^S}mPDJPa2zXWc*HttXtvirmTthhwT5gKAJXswZpP z7P-;3ZQsu;P*Td0Wfwlo%-G@~FwWu`1@@mQFh5OZW`<%gNGYolA|v8Dy=mLFwvo1N z>wd_RV+WkV#*6!AW~Kq)uX`Hw_a4eU4Q6I$#=aocS68Z1PHfAT&X@pTxTT9%vLO2l zNc;s{1T~{@Ac1%hW?p~YC(H}qm?E~Nn1F4;5`~%(sGz`%-~k4`FoNoZ;a#_QeGmsr z!~uK?&i?}BzfStR0`>HB!5e5sbpZy8JLPFJHgnAf))&(U(U6r(FFO!)&Il8f0lT8 zRa&B`E}))%t}J_A=zPF;!(yGxUcX}ijuqZTC=lBJf8*&{)pZYEjtz%!#)h3&!J6M^ z2Bjzfw*=;-cZSZP-o#eBxkGAe27unkg1b9lv3~XK*#VvoE&&ieLf9$n1B79KZJ&Ds zkSl^~a{_qCOw^0_yPn=gP@UmOFQ0 zefip`0P(=b0Mu!z4>CLzl2C~@09=3L&DHC&1}-3%S&(cvG=6970>L(2XHwr3c1zKx znPE{o`_Qtj-47m5YtVrF;3gn3xxxe5@xXp@gUi;~_*;x=QXZz#?dLb|K3};e9eZ~h zX#oUCZ3@&HY3|!c@wuGF4u*#{!9$Px@QvpsJxmUyq_EfpIJSX<_6MRIf}LA)oyq;( zqPeS5smd+={qCgwDQUB)G4bXTx<(Btg%0?pY zQY#2EF~)d~k8oV^#JOyimn9d9UBLO?A5dJ3Va(pPN?6{4;WAy*T)M0fH})Zfhyk!4 z$H>P92w44lKKEJ9d+2p%%p?W3;}V%MaR@#A7#%>wL^eTGCTBdXA^>vEmXHAH9Nx)} zfB&4Hw?eTwg*7yPj3nGxsUIG?uBMt>ww zku()j4XtrhHP;kw?GJ9Df>l+(HLhU-htDgll!K!HyS8?1LoK=*hJi#v1c9$9YkZgs zAQsUJ;K+3U__v!{lg2Ez-Sl#An1OJ^n8B<)NFVKyR_Fuf{?HEEHOI)SObFof%LXWj z`SZAye2fYUv=33treI?OnEDaJr-@vB>bR{Pz`!4xHYsc-C1?sxCqBg(J?YhN#O)5N z#BG-aQq$Jv;^Mq+mug{Uw9o)f5h#6j_o2jinx?1pKf z5LrNr1Ugw*DG5bzPUYhiTx1W+1lR^!%ir~#04f8s$ExXGu(}_V()PVGKU2V(S;q#f z64V;#f}nQ*o?b{5Zed?W@u4JcM%#62KXttS^lpGyl(bK`vU^K)7yvdij9@+TG+f0{ zl|VZ!UpHV2#{cNg!^_X@|JwdOqNQk1#ujSIoO6i?-8zL3$d=_rrXYD`FWU;2YGZDZ#xjW5aaYMjtO+C)|b*9?=cF$n2c&NX$wY=z{ zhZa^a5AV^0V4aR7A}cL4kLmC_EUnEc>Trbaw+A+(1&JbgYEw4GoG-ec6tdP@*c9yC9Ye8(tFN^ z*o0;&d{|ehbheZ@B}kka7-=>OC!I|Rg~rc+4usPn(^Q%Q6$)j{W?YW9obGs{V4XAV z+cdg|WBcgbxp>Cwl61}MY@*Y0-}vwGL6_m9?lOea#jV$?5pKyT*q9DT%~qW40e1|5>;*2VdF{k{h;GlE=;SSgQ#kJ0Tpt{U_1#L;RwxBq!lLKI3J$2Ru zAc#yPb4sEl7Yd~(jT$Se@lY)%ndt$+-mzJhi&fh$rc(41-ND1t0t&(0Bde|h+;aRk z*8;a)oV&43doVyqh#nH!*yt@rBeyu-dBOP;dn_~seih*+jI9H@p?n0OpoEIRHIN9c zSm0=(-A8!{Q*4eAR0e#tF~ISt#3vd?j0+t<_xkOY{bOLTODqBF)7-#GCvx@@C!kK1 zDTVB68VQLIn&*hb&|@2E<86xpyzz`X7#iSR^h4J=hBKBdRuvuBh(Z`mBEcj$#Y}5u zhzQd}7VBVH<#sWMUN;R>L_pw`Uug5;F-XmZegiO&qhM7j)zl~sHm7~o+pQ+~PyV%+ zHVY0_{p6?J5DhiGh9jqm)fz5D;Rr{uKMt8GjD^ElTuU&YrcW^N8Q=7_S7IXfqT$eAAL4A`+6xqE zQXyM!|Ieg-Mg;`J1@Q1XZY=Mt zWLw6=;J>~(6^#ojgHE}HyIf)Q$veQr>R<;wt*am?yX1}?j;{4DWPB3%0vKfmz>WRX zFuF_92!?AQ1lwT(;2kPajYKdbeTzP6APO!z1T0zwMnA$K1GNO~pEhs)m!H^M>;>p3 zSLNuZrW;Uk|NX_o?K{4x;2o+u<&2PgCKM~NA-hiV1&=uI0U{GAUsRhm15jqZd3TE7 z5>VRzHQPLfO!5>XnH}s$N(Xo-d1rJhO%IXaDXXanvE`eJ5}Rv21Bw({(qJR|6wLrL3kezod%6fu3 z_Rs(P?sGT1v(}e40$F*uu!=%3f?`xT^f~e2o?gZW@S#t?dTRmL%QA;#bFy=$>^`+b zU|+7Zczu%ixiHQ)e8GIH0@#r^Hq{U-SuUPSp@CC84(PN#GAx?~-252R4S9dT0eAbG zBU_e1S!S~T>~wd`w=gZ=f_Fxd7$`)o!m`NGCS<2=U-!|sU3~vZz>Rv#hx@$QfjE^o z2|xkwNdt*h0M%T=tm-~W#P!a)#^&3y0ADtUNNZ_<%f}Nch*C4-Ga^9DETF(O!{Fk! z_WN*EeF`kF-*2m-Ldg2GHtFGS-g{?Z%>GfoI7z5B#pa_*oCK&rC!nh=_pbpZ(@8&ijRqCnwQ^~v08j=fNC2wE*=`d-aUR85`l3zm z+Y5XM*%V?>%5u;z*K6nJtYk@9srUV|?_VgDrM7GZpv60DZ5Vq_*EP0RxBX6lIO?JW zHw2xq)tHNH+5zS{C#k(bQ{dyMp>Soxwlsj^0`Q588+YUGmy^x}grtElX~mwMwIYIY zpn5nGyGmgPX0k5VsCx&u01Z~;8l^Krkr);S2^(D^Gp<>vvE7NQbLL|ZHToCa(SbVx zRjYtJNI_O!XNJ#H*JRx_tV{E;-Ih$H(guJMN>MH^J|;H@*|fs-$9IkijX=QUU35Lv zTHq+ni~+E$2CY(rVHDbF-}ua3ju^xTr5aw`zUTv(0f0v+|1a+*%YJazCqI7d$RAJa zRiQ%$g!JH&HkfB%rop|o)TREK{*dKDrxoBtjP`EP21=n+A`nfp+&S1$tNfN!s1n1m zx^6JOQ=!8n%wIC~1h56IW=J(AsekemXttRDs)XV8*uokGtUmT*08v5)5iphsC~WeYR42*I-688_29eW2M;!cpOrma^1% z`t9HA(cy8-7};mwFh)TqbpTbgL3QzyM`TNH{vR|&wRZd93(ozQB{ysog|j9`P=E~$ z&}nSJf8J7HF5hBHcUm3L;TFz$ZWXN!b8Qq%<38(wvr-L5`Vp(BtsH0~Zo{rn@$vhQ?wpPSUgh=;zXl!$*s= z!|xK-PIT!Gz_a7aP>CImwLyLnfI=4mC|5YsW}v^!Omrz0+XsiA9pI0DreE&L3@`&M zh8sNJcCWDyU`TH5)DvEB9O`BILoG{Fju!uY~w|R9XC8Y$HGHD z-tT;6gE@^6lb+cX=j#x&ZI9V)!#X>H93cmY2fceHWl*Md#Agz3F(aZE4nnQ&W2uE9 zb2J9I0O&K*&7wWF(zbTVltyf*c*w|F6G+Rme(hg_Bs7q%A%TQ!y656j_l3Zi{%u>d zGBT&@0MrB+0B73NY!0GyRUV4$;hgc~X<9*nf+oe|teW?Z!#NxTfP?M%`mN5Xty3CC zu;b}LT1W?E!Lw1nB&)8clEjKw?MW05NO5+yF>dWLCJ%5EK*VCt*2*5X zzIgbZ*Hl?t+9t-6z2=+&wYSD0#QUyPrb;q1mmrHER6e-1+$UFi6#jt@{4c zGsI+WcFi<(s&#r)XBEs<%6^==M`wZZd4O~g6$@v4E; z1rl#ADf^(T9}-hXHtl0sXLtulhiaq4yo^{H71b%#F%3m$o0F=6aS#|Cy-{d|r9JDA z6AP8JN?n#%tlG)7`GRz%rZffFm@Ieq)W?FIVD81Z*z5|dMDThC&`^Ba@4-6v*cLPv z-C}lB5IM~7(lui{4WO!KV?gIyZeoKKR?z1X>sZUL)OvKF0G|-CKyDi6`v$u=7%&(? zm@^9X^cLRKAsvcHE*>5*d36q%d|CpKqr;^Y%mikEwME!h)DlVbHLR$Djx(gIBKj0i z^wfmx9MHA1k32p~Gz2E_9zmI|Kt6f^INkI}L0IGnpg@TZYSFPsREaUNQtHnVfJl%; zxq>kG=@Vbn!ii;oG>>G9y-g_F1qe-qq;?XKK#a##0vu!b6*YyZ1n+WbTa!kBGpEs# zZh$V=E!Kj$r1Kwf1ua0}R{@t|M%02uI;6=W=uSSq8-+-(otP_tuSZI3=qhRX#P;_AAuUq)+U6ReC^)**_p znE<5$C@4ya#xZklvXUG9dw=pdw*fUQG=X^pQ5ng~W>gx~j7E;jCW32t@W_J$ra&?C zpdJL~tDg7g#lFiQ{QG7Aoht{(9HCGzN{2qUSc*RIL7k?N*gnVY zX3|-BD_Kx}|8({5f5=E?C1{zS>oU6loP@=z&ILwlnkZ5bxga~%-&QtrcsyC>QA&~0 zvHWQys>s4*5d}>S^ZRGZ!fE?7{NGsr(Ca$<$g^o**I(xJJDlJ}eazWJXQ(OoT=%Qe z=l~bSPzz_%tT?BHG8V>fW65S(6_o~Np+g^_LQD5a8S=%Qb)L0(yzaA*o*n%0%f69w zH1+?Lr{Da94d4GaeQMmC2mVzjaVGQGu~e0?q8jIOgbvnib z?}9n8Ch7{{{#9y4iy65c$e+3ikQ{C7|5rctrOP+|Z`Yv>_aBhSC4A)JN6-cxF$Rc5 zk*b;ray~u}CLD}B`+;L2Idt2@^7d`nlq@|RInoOj3>r|vLFF~Dmd$jUW7=8~kZnPe z4iiKhYuqAp5mR4e_nv=aA2KDvfeMU5=P-(duq0}d8lY(TaA|Ys67J>17bITlhkwf2 zN9KO_gpu(}A>x$oFa8ySUpxks_FbcroXIU%F4~!ZBm$!S^*{nTKqG;93V1xV@(dwU z6EYwLC?M(VSV^9WipH?TEN6U!!Mus1CQNy7#i>}>^*Ypdcl!$)h+`)fDWpP)ykm1c zb7+g%LKB;s={M&i>#bey0du~uw%W5T#l?MacqI{4AM4PMZZfl2FBt?tD`$>27xvUW z<8*=+z{=@!=qHLn#$zUceMB}DsU*k&T3{xJ0;ky1SP%d(wOKpF-x09TV6tdD)l(O| z7X>yeFMDN45fC+;QK^_H>PU4r0T#lvX^9Nn{u@vdg3V^bG{vTR2t&V!%A!{j-1Vn>*_JFkGT-RExoR2TYu9t`B{cgJjt^&GAwuRRQO0TAhkFg9|Ru*uAjOc>eASj?X>|pnC@s z4|o+7T~$dTAXiHDsl=jd4w75d#q9>RFGk9Z$B5&|9^ zFi((7easZt^8ug%^w%7iU5|*Y88k9&?DEQtE%<;yq-GBbN`OLQRnapgvNH}L(EvUB zJ8auC{#o(Tex%d`dMG_~XOr#tPiUMW8Lj#G0vwHyGKVnAY#%`~vPs}(>((ie00WyW z1TB4pa4L#MU?&vepbu1Y?6Ztugoah#z!Ht!?Nd0}05Aw(m{YT546TIp$zs3lJ{(*L z^ZCYiGP}n$Y|`LwNz}MnSm$6(u?I9Z-6GLRjZkN7xDP<4=^RrliYD14q~^f^Q%XpT z3xS~pB>0=CjFrhrU|MI5e~#<~_jU0gLraKtvS-dj4Hhj@Oyw38W2NyCE*k4yQAAp7ecr&W(x3x;MI?^G15T>pock z>4QtOta_X94$tnM7e6$6IGcI0SlaY@R>u^O(;J@?vw>D10@QErlTsf}AQ2Q23t>Ci zW=6rY&%TNl{GL#uY{wisYSXbn=!A6~@tesbnh8Pf834uG$8aA6N4xk>{s%izs}t8d zV!a0N?fi3vvGn+&jfQq6fGe^%a(n@(Va9griyyfvO_LWtCbOs@BstD1-3IRn@_l9? zR_F|Lh^vN>u$Y8&7fx9~BdTr^6O{ItrpMOVuko;_{+d$!S)c-=^6Xido&dI9?0eVy z|H1t=J85C<;q6ga_>DwDUUT(oBwv=G!4wA~v(z_c!Ds*5n`tkVnpdz;He>6@)6j)v z6a%z)%VUi(K9p17B@C5OVI~3)F{c35Q%wuNi8L4}5`;-8O@q3pK-&8B=X?zqn>-{; zDw-w@ps62LQHTM0r#fA-R=`UQH~}xt1KT|2%R;_Bmz_(#u>o9>HA~>^&+V^ZzHqs@ zZX3A#+F$<7PY%kv;IRfR6lOtCW0biuK=yHvj7xmfOow6(75)K{gtSl{ugjw9O=^0K$R~gNjv9rVOMhca`SJ?7uaqCjnJ=HG3KJY@CV2lf1b%R5 zEcFa9zkx(#!G|ua40E?v8+fpE+h-mD1rXMNV%D({(e8l-5J+HFgdxdPBr4+!c8&Re zgIc0GErp<|WL|jDoR~r+u{fdusB-Uf=szv=xf=r`qn!{@_Dk z4zK^yZ#Eb4;(hOnm&rZ|JHZn;NBtu|KK{d($HQ}j$>_dex@MK5K8^wX|F@pL*LwY; z{noP`Uy=6HOTL*lya5`qNsV;YIyXaLtXiY%0f-YA|Le;l591!Rl}VmybTlei)6do= zk_9X(fYQ)uemryoYRT~phAU|ow-{EyrZPHiWLc8rL>$+6jIyOiPi{5F_SH!4#QNy@ zb1NiNz#O&$-rF3GKX^R>lt-mA;4h5E@9uxSwTb}WJf4lcIv45ZQcHQ4$8McdJuQajyP zmJ;zTzijS0`>JCSl)|DBMyUpk*c!1Gf&)*9t4tX{aJ7b2fnW|%BIDcMJk{0KULqa* zjbq;IFCKnn)mOkv=T@GH%R`Hfp^v`m2bknzky1%S27+ibmBJ`MXBq3nC4cyf(_sD5 zUw!Y@;g=EEu_!bf3&hK?L@YJ>knkb3hK3e2u&e33Tv6dwO(xNdN&+ujoWNeue)7F9 z-@V((L&q$J8jk))e5JK5LI)TCam}J(dyFj0;B~J5k5~Bx1dHyZ=MLChT`)I}@J)ZW z8i6L}#xDTvdH|ASJ%$drz^E|mF2iW5O#pgl)-LXdRA>N>WEovDr|>zsq2?F6cI`4` z9Gwy-JKK^YvE?hZI+E#zlvO=mk6KqUFOfg$xJ3{tOTHP+hb3*~}5#rXC@-1FDujRRA#f_kFsBrDw=o7?9j8oGP zXd4t*G>7-Mwf`pCMY>*fn)KS+SiJ+68})iAf=KVuH3DD^-%ius)E&zo5)I5@@(Z0= zwqc*f2_Ap4f+k$S197d3Pn=DfW?4jjZQC!PWi-_Y$xJ2@ z21LO)5!o@^ckY$xo>$Lrc>a|F)Bz#`5hAg;TLnPLl$Xz!RRXxM%-b_qAKex%V7@ru ztN?)ptAVQmh5Dc{4YMf|REb{vXHcU9leLC-yyw#nLcK>Qra0OF9x|zcYU5S_-^j)n zbOJC*mqc7Ot=#}m>U2>e(L_oJAsrkG5$>)069#nmm;-bokCN(fb5snAFZsa zE9mJRd(ZwMp~)gwr^q(Q48|0ECaTMgdeg)t0w(p4+`v`wCA138;C1>1myL`=5+i^b zNirV=2<75_fB|)@Z3Vd92v)KHB0)giA0>$f(WpQnlmPSucN6k^xTj_THwHY^00?0L z3{oI)NGQ04H{kq7+Wf5V7VkJk2p_(D?h|oT5TlT*h$*x4*Ri3mUDLqp^SyKPnzOR~ zI5QJBui|~DT^*4(JHu!MKn^h#XZo|w#GdA*cfM*s`=^>4aP=9FD93>1imxg3f`~@J zijecshyXgG2F-Pq_f!Wnnd0-XXECJP_?HWJJ~B3@e1s4c280=aP?DfIaX&90?*0HV zn7QbGmhA@MC{!0~>wkchB$`oXJ@Akr8I3DyWH5}+%Zn@W*=Ek6#+~uh2b6IFecAyb z8ANBS5r~Nr(ZYZJ;#Xh&3tRx*^Xi^D*Ac*ncJEmRO{`gDBN7<^9R%D@@r_hwB>>#L z7~%&cCJ=^&TbQVN6)5OKW<)G3R9YjC9%y;A^HxGuly>);$_;(AI2)W4mVG-vbaE10 zYmkE!0W!O&d?e66Y3|l~ftU*>w~dwuv^Kb{Z@WB{2c=$l$no-UbgwSRiaUg81|1_a zYo0)vrxLS$o)^C}|HIwc9lJH)_YT0LXT{|z`0U|J`EdyVB)~;!_=^DR}*1fXbfNmFf)_`^g-NR zHC7TkVe@kzEx-n_=EG`vkP3acBR2^;Q|uP@v*u0x(F-|ZwKe^S)c~Ar?;n=XpOJNo zUT^GBt}IeWGmc;O#sB$p|MZQU`s2cBFt}V5mtXOt*aGmP%;^XzVge8Vi|f)-MvV#q z7f*8sVdJ&B^(7j09l(FA!oB3wa+WX#Fr&KOtpn()52=tYWNvob7jT}Lni|IiyO%5L z^^GsSa{2VA4G3szJENIVF*KejtRLE6SUcn&2Bn?_esm zPa(0{;a&}fL7);_snm1>&m-6%>puNQ{Y-dqf@wVP=??>ZL>Pwyi>E6ut^t}LAXqdC z^D*ZB&sXMGZXzJ{gUQADU&}rZz@NYg8j+#Svtr%H)NO~B*8snf=1((FUOooGozOgx-!K747}%AA9{NDM%sa1x#SSU>>7H6YvZ0XH6y(uhU!L zGw;V7ZFKRR3^CIw7adL5-}KJoGMf!NYXM45fATpmx=83^D@TJJPf%eHU%v4)$G-)F z#-_%Y%@3b=w!^#SUO++dZamj^10Df60Og4#fX&z}U?71mS_nQXwhr$A4FC15IGJ&f zZIDp|-R5C%A!<8&m#_by9W2Et0I45jIsnJ~16jdJqqIgQ$<$Zucz=h+oA0kVU|=5N zG{cj|z#BjJg(*@b$T6Whpcox(cnEN(e+g`VQrkSbc_zrBT2}x+*EC>4`2aB7OKmT- z5a=$`*WjAv%alu!C6#zxbSu}nwR^NQFO~5l?P~FTzwrx|__Oi!Edq$=4GP^?ppj=| z{15(cuFh+v^T*EB6g3RLYxt0OAStOS|q%;zuk~$vJMTo#5AWe1)4~tk;au(T1 zdBJGJmBhu8lTr;fHigs+r^i4uj3>&;hqrEjND{p;%l^>eJsx|^!B_v@&nM;HkAGw6 z8w`Gtfe+1HH>PMQ)+q4$(bHqcO*q9z1bO#%4F$n0`9{LEiQI4GesH$4fse`&KN=i7mc3UB9J5QeK_WV$ znT!ZUMhkm$Qo5rF!w)Uq=~ehL#=_=Gq}v~V!!Dw^(~BWMpPg6cbtF7z&q@szUMWvhxQ zJoM|I<5V+#^C6zTZm014f(RZbH_xZLSB2OKE@3H7d(pByGwAON^97MROTM%Nqpx$r zVggO}zD%1GC*b4DyJjGx&`;_Dp~_hf!7@ z=VmlB#)qI=rbGCD5B=RRS3vh zA{lK;kB4D@C z+7oXzQ%-wuo=*?3Z91y;2rmU&5uf({>8>{3ilKZ;?s`BMBU*GAaUtizo=*iVbXG=6 zKi{<8bxys}%#d1w&{iz~?$Re=C)?fFQlS%?S1~vQxWZ3H-9ljfcopX=_dhRP-bUm? z-__{Xi9pQ|2bVjz;Owj6We+~P_7OqE1Ojjk^EbvYk83Dq4GY5o0_M4op1~DUuIT~1 zHjM#E0IagCmi0e9Y*4Invuv$<2oMW%9F*#|w01x%HY<5Y{yuP*KG_cnI2>|is17=T z5@s86-g)OsyM}X1(4z%ReP`YA=%3NO<~+2{E@8z~L~0s}h^sQN%0~vmGB$^sD30(O z`Q1eSUjHypxI8i|fopL6@e)%%0hIlPzJ2nKDC01RQjiAo&?cvoY?X#jy4)9Gm=K_G zXcAHvNK6W&%;ZSugzD7{wTUt#Fx*jDh$N?SOTx&Pf-%31nQWT`eHuOs-;}8@rdCN9 zHBfz+aZIcKa#%)kw}AL;Z%HtUrGnqc23H8KJ6)6$Hu<#H?w`u@KysZ5dRmZ83)a%Q zijbPhIL{~6p_oMraO3`Z6%22?%CKp>rLf5fVL0L`5CGpf<%xlzkXTO)?L4dhoPays z>-w|zxhm)Q>QEzfHKFzMj{%0R!`Di9=-=rio&)07^KYiopE^>Ic!2I-Yq)(FV zm;vdtuLP3zSoj>_?MRF+VCMV(*t8U+ai3F4Koi^rhL&l(Zwxd6hb*TBwdCybZf&-Z zoGr$j*&(8AyVc(J58SE?U&GuvtDpN0T zLMO8D+r3|!Te)jzq(|>P;jRCC6`2y-fk9y7{{6-Csem>x5Z=E7Nh==p?hfX82L>g{ zeY@4^&TNMGs*^4kU8I`>Egyw-_8Mt9^~V29Ebpnz)&wJ`?m1)X(aw@gSPA|Cv@ zybXVoe4+}LtkeGAA^`WY{%|&tE~8w%(T2afPMT5JQd9K^6p~JPqTJiGX#nCxiI*1l zY48|;!3ZMH`1BBathqN8!KcZD7yYhx8vw1i_I>HQ@x!`PSC3{1Ooj}n{tcBBp&CIu z(4S2+Rsei>H}+#O+dtFT=pWkFcih~w$ygT2`69H%0VzwFSC?_O)aaCWn8vx;UXVv< z;%3{M|JO*_j6J?RO;#p~G|Ze&O3)iuKDz=E)1KxV*%6<-Q&m>a`N7X7pls z?DWFpabhvzK_m@M=N@mle!cq+n>RaWb7t}wmS6u@DBOKH=A1;SB_Zv)H5Y8baX!3+ zz($+A@anmh{=hl9;-sBuI9JCFmXBhRNRPjgUE1pB zFb!Pyq3;1$(mm7?Bw&$AoRvPjd--&yUQRVHT+RfN2RFZ3qs$L+e?eARmOpU)CzWL& z|3=%Y>V!>UMtdkgC8qJ<5*g*|9oU!S^fw0TWi$i{RWZRNCNs52V7ON3C`YqHQYJ0D zn7^r!0? zC)fchQvEfsH$=_nY0|-VT723z}T;V%F^BaeiMgJuQVSG z%C=M#%B*y!W(RJ#X}sa$kK@_LK34uR-u$>Ru{`KZN}a`#IElHo=F}Hi8FV!sb~h+$ zGym*!+5i9tV}ON}x$V`uBsR7V>nCn~{p|c+`#W6<{aqU1r7w~6nt>B$;BsV$A+jxeFoWzcgi~{2XWRuhx zOWFT1@CgQf13+BWX{OZ(qS^hAi5g7>SwYk6(h=F>%OeqbwVtC7vTn8(4g=Z&#A`HC zBh)d1b$B>*@<4ZfA2{B)gZ-5yD1HtQJz-NH_+YYD^p)=G{D6(RH;vws!su7^k*w zep~8{CCFmnx10_TS5X{qCFaz!i~=fkmL*d_+V&8W5Ey&phw3B;J_c*QY=OADxlsmi zcj~IsX4V%4kX4Z~A|Xp*H)vM+W}h1hCkJ@!)$bcSJ~9d{6JV{+1IXgC!+%b&zX0yE z_8+Jgn#^pL_Kd;{opZ@S-}a!%rC!K3Al+xGvDhML9tQ0pS@`6*m-F%C(K2}uVD=lA z?HQnV+ccsXa=#m!xRy~N*;L6Fq^-irrqsPNj=;qK4|893{JFgM_>=Wi;F#_OFP-z? zi*+5sUTp6E5E$gM_0`S4=xx97odB6uU>Z|rEYw;+Ib-P!UnFZ!jcV_Qas|#C_mF+{ zZtVIJ?LPMpJzf7iEw9v5t*fm=tQ}HpBUe5d<-c>}pK$KKRAXm)xHm0R#v^t@~4*Qe|Tm4G0CvQ-{;6LRI9$n@~>ly zD6&#lmi%d^+K>MC59NyN0x-8MGknanb^f$Yt9$$R-_0RIr~~lYjov>z|5ie4YPNY& zmwC=GFJ^Loi&Zrb?Zhh(#%2voYLx_p3L#rHwMMfcH)~MLY(Q&5JE8&f#uR3;gSLG^ zc9phF+l$MQID`b`&NFyaC*|N#e#oi4PB-SGu-6R-LY#VdRf`KE_`&=Cl=mO15$g)U zYsb3=iWGgU@~`XsPEJ(-?$M04w}1LS!aCkvQZo}zKu^l+Q zE$6^x-odHk8G4+ETNuHZIK;}MW1PPy8%vikrItBvhB`lW5$T_-^bd;i_g||8u0q^v z_6FPPq07SD?~jEnz}(XIzog1^WXKt0dok<0(-ZG+%JN+YI7k$?{0SXtwHHf}Mv%^Vz1tXtt$W$Pr8`|8^p zs#_<7VWNY!o!BP@edbvBFHBtSy1PSt=N*ncDs z3Qk$(W;;N6?BdLt@8mO=djSQW{*C|&YQ2^PcioBb3i9A9`~iAEd;potjQ8D~-tO!F z+KUsiQ#89-7DcC|C?u+#qLFGxK=GizRhi!yfZ45=w#pChl7AclLO2MbUuY19-+qFsj&Pd&}wV(gaC*IT?c!hQa!u{N8te6YG*z$1;x++ZSL&Wq6@JQ z0G^|^<m5K3N5Z(uK>x$H20o?dAAf(*l2M8 literal 0 HcmV?d00001 diff --git a/public/icons/anomaly_singularity_isolation.webp b/public/icons/anomaly_singularity_isolation.webp new file mode 100644 index 0000000000000000000000000000000000000000..fc37bc4c247001f608f263d5c3f2e240d2cf8b38 GIT binary patch literal 14226 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 literal 0 HcmV?d00001 diff --git a/public/icons/anomaly_tidal_ascendancy.webp b/public/icons/anomaly_tidal_ascendancy.webp new file mode 100644 index 0000000000000000000000000000000000000000..998c75bd1d4a6e383bcde0c5918560f96c763307 GIT binary patch literal 13728 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 literal 0 HcmV?d00001 diff --git a/public/icons/prestige_absolute_zero.webp b/public/icons/prestige_absolute_zero.webp new file mode 100644 index 0000000000000000000000000000000000000000..bab9e71630a142067b3affe3c2e8530b97e6e6a4 GIT binary patch literal 21058 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 FWdbY^Kv@6) literal 0 HcmV?d00001 diff --git a/public/icons/prestige_stellar_forge.webp b/public/icons/prestige_stellar_forge.webp new file mode 100644 index 0000000000000000000000000000000000000000..36494ebf58fe3bdb1ed50d6c7add2bb64c6eeba2 GIT binary patch literal 27718 zcmV)3K+C^UNk&FKYybdPMM6+kP&iC6Yybc+f50aY0SOY=hHY~IWJwIb|36r=yZs=d z{}X^$+vY$0W`JZ8+Wu1?y=i*^A_2S+9bDHOvVn;N=y4(WNXHzb9|2fyeUpUe)~aMV z3Y#xgDki_V_a>=syWD&uT>>_|x5=b!JD*6}o20Eu5s%DIK1^2GPEn0kLSMQ_@`HG( zFWsb^kc^O20;xkD&uyi7O^TdT=>Z(W%;dx~f_d!QxAk7!8!iG|RsDsg@e3B5@7_*!#_X zB-mB-Ws|-hd~Ancm1$$eo3`D5@*F@PCVjn-%pwDHd)ee0uRrd++--BamdWBD*IFw+ zcU7CzrSc^yOMz;;3IL0VZYIf!q;vtYovNxN7e1^?SII2|wxwb|kaTw>TgbVjmZZ0K zH-I_wTDso$LNG5lljPf20iF)t6Me!z{D0pE)KqJG8%YQ8^6?@*#7p7MNz4jn8MFTt z^v{XrqYy@uI$5d-E0Cnxb|g6mAp((z;JNSZ#G@K||Eozns@`%Jh$IMT%eMBmZI(LN zAcWCq=!qnME+l{8{{ccJo%ZAB34EDeZ+llf{ht7wN)RaU9};$0(3QVUrhknJCYEV+ zeYt5y04M;I|E&OM9G(J>*-h?7b|1egsXGtS63zgg5(M#DSD*%e-cKbE0sLl+*PZb&yK_aX0i?Z~9|%Qf{Y05s|)!$>>QBHR%Kxw#KQ zupx+$Cj;*NL8FwB(n^5lwdOTI(~be zU#qB=RO^7P7d4HfWu|bXQ2epvt>@5Ao}+n15JMCP#Fm1Wnsqd*2A5u~w=}o4jHHle zAFn`>MrGviM?rjIWFV@z6aIY8IO~X)RB8flPFB)Y8k3?F6rCiC1mVl@YLcM=0E78+ zGmJF@^}Yt&RGH(EoW`U;03=D0gzK#jjb`_!SIP5yWb zpdV7{$7d^-Ojy$y{S;IG7wuj9lHp^W5om~?=JWv5*!Q@$R)2A5%E*-ZdU z%f0TX7*nwIek6~$n|HicD8SbQDB$ahNDa&I3_AxHfsjKUL3BFclm+i9-}5GojEj*G z3f5s4c21Q7_ZlXhThD6uM{7q4feyf)J&(;}^YL;m7h?fMxCx9&zfl3 zCNo!~rebhH%liBK(+dE~>JtD61T!;&gPC-dQq!S0l3+tH0kG1o_?U#lnHeD2*0juo z!=k83UE?EEpjtKS_=Hy>0?d2R12Z)gK*%YRj!0dMBn$vn>*G7?@X3eatRJcGRc&=` zH6PJn0RTB&bdH3ClD>O9c*|QC1>hr|*LGgp-1|v6O%qNrGXqeK=){p5%HuLdhS}zml!*y&Q z{&d~E=Avd(TV30Nssn)L`f@gsCQF0B{h)QtGNh5NZVKdT=6e<=tXQj6FuQgxipmxr) znkO|&9)5kDmu(7wAe8mi`{rS?g@4WztVY^6(%a|fTkjsWOjd`n4%e4?BwJo;5ncvU z>GwA7ZMU}_(@%A(!TEa8tw2(1UWPi{&3nF``diuDzJ>#Fed6?=dc zD*A0CEa|lUQP0;4>Xzm;G;+KMO)fc5Aiiuujle-)i0Rj5tX(mP&|ChDzu;r0s4cD z51>DQ3n-ukAGv7xSTt9DP^8qXnVBRJ1TF+L$y~+)9xNe`qzbtTvQ09VLaETQYk!hX z(W9j;`l`}a}>=+SXNR+i9AYy4VG1i{_kc0R#|wB$@Lk*tV?6IjM>Q zB5lzmb0=|w+LlI>%m)P#q1z;xFHtE6XnrtB+NfgfF`u)W+wA#qemMWRa%9_9ZDoGk z-5oNw8{tkyc-q7121KqWqyhhCI7II5?*9Dkb@u&|x{YmHRTl1&xrNpQrBND9kQ2GP z^Wav zfdMNxxCjb@gCHRYz!40%GvJ?rG16w(Ohi>guH19>TAsFTTi!^vt?Rzu@6#vCc0huo zn5*JA2J>h}j2`oF4rcfpj`26DHyENjn#?1*13Vy^GaV!CiZn|4-skphTazL=+O}P0 z<`5o`RHXuyx=U)h`C)#xo)dXa_1R`-Xa=dIqRfnp@Nh5&ZW}3Tml=+)FQ6Dnk{d~i ztVRz%e)odB{z|+coc{x;G+R_RUIgEfY^%0y+m=VEwM~YvIrW!jYQl3dLvJm=x&Ne6D*IFh$cJ!kJOZT1De zZEI5`Nw%%;`$a@m4IKiKGpp5PX689>=k=UVFkfmu1;bU%dBqG#Rh0|@?%{6cZl-Fg zBJzK&g`4LyV7hHv)f-7`pL^eXABfF%Do5DP02ebeGc#AU7@O6vf5FgWX2yWzOjD3q zA|_<4ckxN3w0(VzG#-F~=BlW4!nG{@nfV#BKz}7)?->;T8_uMpC3&Jg(`R z$>0k-YumOqvTa-6&q7~Ap=$MSb5e`GhApQ7t$bJr( zY->{_Nz(g%5m8k$b9N7`^yFe@9x)87x_izm=;ibRXa~&9%#al1krr<5MyewJ@5{Dr zSCS-2T5I3?h?oV7*e}LCu`lbS^~l-)9YX=kMD*_6v?NJVBw6a2HL$#2yN~()=Xn=k zes8e5tI`#=ZG#lHJ%BHO058=7*%JZ*`iW`?Km~1c2cgL(Uk|TW$!`BY`O6oVY_)vJ*YkfL20;CVXafQOyB_lg{T0{$dtfj8 z6mD_93b>T>9L-v;FY`Y7Fem~#yElQLNSWTdT~QVRVq=3Dz6Xzc(bg1Ou6A8 z0p%AY^`U-XHhk8f+IMh$Z6EUH!Dajyn=B9I^VFJM8z&b+7NblM)*QqKBq~uYT&k3* zk=6E6Q74UNdssels;|7j3K`?q{Luj(5dctEx=aMbo{lP$Jr5-M^pL)1!=<-6_19(T zJ8F?2qSK~pztkmD)mD61)Ie1L004jpWLo*q|5XhgKeJFomza86zq(Zh`Egsp}mR`YG+~}lQZ*IBk9Hqeu zCny#N3?|3P8grTj=9#X7lR1<$C^gU<=Mr zhGTH1R-{Xt`aK%n)K|Q+o?hC=}11rr)+nnZwOdey0a)f4}O_wA|w zRR9zBsc}Y!PI$|KIs0bhtMBdSSFG!fTMFQ{e+5tYUHj7t@2~CceABb5N_O2wA6K6h zZ7aDnbxXYLH*A@aDioSyqnKq_+Mw8&;I36Ti;g*Hm3eSxkJ;ZvH$OVB_c=qci?mup{(Sv+vLW1R)P?ZV?k^m)BCQib~X`xy?{sdSsw3(F)4tQMLx#2Q$GCL;L^_mPoSn)V$%U)r~r4d z+Y+@6nl*`vL8!>g^hpRX-9rHYP(2%d@;muFm7J&MbI=%l68y zXe=v`b4K(|v>s+O$yL4nuDZQ(yS}-0a#l`@!x94k0AR~E^!P0f=V{kIv5p5?Mtm2& ziR?XaL{U4cE1EX@rJ^kqUe-75Lpy)TkN3~`l{N{Li~5KGzU>Wjgl2iSd10e2K$oBw z#FZ51r4pvbe&Liad$r!3F?AJMt_79)^rMmBAI4-`&=F6IYX0#%Lx0Ho=RxR6@-PrwwL-&0T>#aW#_WXSXu<&iN z^(Eojikz;q@7BB9aG5V{?Pp3>h-&l1A*u_hA#}2exdaPo1}&V$mTueRvAe3`%ZSCp zbBCSx-%9{ys8heiwNJL|UNd3LYK`^X21B-f!1&OBp$&uL!I6{M!v|pwT}dHR*GR3A z(s$Q2TboZpb|JSMah~1&uQ%Mm&sLYdYCB&yuPoVNcunlox+r5Vi|lh*w}+H<_?UHm zn0-k~UvF#K3sn*oNFc3FT!uOjVA*BsVP3pe>DBVfNEW*B+e|yZtff5BfBla*ec*x1 zybhPhIAn}bDMG+L&3eS?G|fFjNrh3vg$KN1jNsWlm|xD#xzpU$&+OVqY`s7s ztT~!Tn5Z>9R%NIlbg<@BT89uik|JmKov@ZhHU@OD_RRa}9^pIk$s$MqB*BdOTVpTv-ht?xb^0v13Qi z?B|8oQsq|hByjjbm20%Fk-lZN^YMq9c+gH5IgGI6*sMN%Cb= ztE{*urK( z040J5qG;kH^Ff9RI9#%DBmoEj03ZN?({HV}zlEmnt#y1HDoW?&!sGB}`X3oQlFw|g z&1Zjq@@}UB0I>Ao+;O(EIUn+SIQHLyQc$PRYpUxw>3+1bUXZ!kJR7NJ2p~3qR8Xr? z4U_-InjRyw)L)x2VB2F3L<5uSwn_R;wUpEsaw zV)tW6OoRbL`=v$DWI$fI>PNn?AN%6kWm7R;@^-3I_oTp8kHd$|S1%v0#^K+;=v#KY9g?8B6&Ms{dej9SroogMn@h)3> zzyC5{hGD)OjP+#QQ%=`v>dLWk0L?16svlcX3gRmb*zvis@rl#e&Si1SM-K(-xqdMl z*@h+UIMZxmsW-iT_74=&IC_`Il5zn=G|H{j0>$#qqB*4tv^U^u3Uw#|L9pk@toU&A z_YT+3_K$2I^^;pSZ;2NjUt;*WS^e>>xtTl=EPMF-`!S5bQLF|woS*t;F8z%1V?00k z72desTe zL4U0$r_PvLrdkvzHixLmAYPvGKzjT>REn!VNLvIBgEiVa1!tfuJ`78J#i550_*WhI zVfb;om2Fn`MxMe;AwU9XV(3EZLI?emWP)aRR#ovCA(pU^*)+?tT}x!aOU>!IEdRp^ zewZjvj!p*wYy*a9g*n$1>#_dKT0bsaGt&)6I1IXws;)-q_b>SJ&9!gRB_9((QrljO z)mWqsAgw9ay*gjVuO=IRIJ>2tY-+nO9AL;d@aY}mwh#-JN zbZiYQSipRKSef@fNmdE+zEBGS`Z8=p01avKf~yRNO!aJ#45qIUS+h0p-2773V?LgI zQTk*2L`R`SUDyi_5ll(6IW$O9j7sKy3U|-7T-{W+-`S}0g{24pvq)*>cflm#!8_`#BxDvA$AZj-Ma&*13zVp93^pFQ4 z9dd5?f?2n^*L{05%|p(5+4i$Sci!5=FE4C!?07@j3xWzx%F$-hxS*f*V5NkJ&G>;( zR9+qcBNK9Y2dH&zaQ^SU-d`O{6o86GdL!Qc+lo1l!FA#kIGg4WwvNFS&5LTCoGR}7g^r2C`q zDT`?YU;z_ChXTzImPsfK@U-N|OB2nRl*fxAThJERfHshA%%QdGgO(0yIaZGWS&;xms}rxPllm^b`84)Tmh>%1V^M*D zKGBx->Ol(?ISUO{Fh)A(Sd6fv=f9ojlZ{%f;O!q71GN)Phwk%Jf_nZ^Ivs9SrVO4zkHOBn~%@SBz{ior1qD1{(kn0Txp7 z?DE-BwwV}~hB79t>C1-^$4oaKmC#x{Uk2bG=hd!-8Z=T-5EpjJ1}{vr2Ntk^L5Hi0l*6(hNZ=p|={@wmtk(Tnd|@{mT~Sz> zR9jF8!j7J*8x`v8M@&(Q=7tL@!Gb)Uz)V64@0ng(04`M>sAJYe zG_~Sc(3Zlv@!*>UCxuJ^+W-516d!}_2Yr3w0z*~_nEq(zYjl)#Dj76_V0&Ym z%?}WAp_V;ICm|xL1%#2qvSZ{?(7GRSd+({4BjB2;iu7sI6u4#-VF7Za@}t+?>}9W& z#HHY(Maf-cZz@ShL+JETHUW**n2wnz%aSxyOsEacYH0Mc{CEfB>z{g3g%N%1xCiktiY|L$V8%2oz;|eHW+L z;D!Ui4HBX$a1;PXS%W7r6edTso{7i`X+iAN34qO(6WaLn+iXf21;Odui#{%@6vS*@ zv)L{IwGhSVsn34!oRw+>I68dfqsK&4Qb2Jzx!nPh3JB8XPSbT3Rr1JXeFdXq(6z`y zPy;j&U;qQJ?5qehH`~?p49F;v1wfcMB^qO%Vn`~0RvPr(j275o;zV4gpb6A`B1$ouWpfK1GwJj?RBacySrrzRgY5F<0A=I*%VrDzgJ~QEa>|+}!1w|u0Ha|Xli>e6Z zPJ$t!6jeb90Kp*;#(+^oN4Eq5!pZO&xE?srpu)~WbTpvrOO~B)TK@3|m*#Mhx!=%3 z2v1<6hzUL<7m;5^HB8UqB6k)pB4Q~k^ph^1o^7_W9UR(FbWGIr&-wNj#T8k};5{#; zGlfz07%vX8fp)I+fe0Q1gDQY`Sc(Cx#N-~;58b4QQ#*LdV(}~CQwO$T4MR)5BLjB9Sr#8w%c#JmmBjc z*;yDcksN*k8A^N{{t$UkL>@1ysOk6UAzFozl!lEsVjIwr zA{@W~Pokkl>Y z@rf#Ael;j}697^vn2O8}gc7hZE{BUSwA2}d!CGTVO$BVR90Epp1XDN;wz|9%wnbj+7K& z5+-$`FlS1osoP2g@Bpv?0EWznIz&JM0B~KFgAW*D)b-^*Jr*UmvWZZdz0^+M*2a_= z2oaRafS_9G!(eqbjAwm1%g@8lSgZg*0)kMSMpaej47!uKEdYQ8HANg^CtjV^=XLh8 z&AuEKd*qlmd=)gNe|hr9AB+OPK|9a^7OHH`R1=ap;1mZjHl^58#Mrov_ELo^QMJ4f z0Kf_~z*@GV1z}W$Z<+v`X@OAbvAz*N*#QJbB!U101bLH3;pghE-St~?gHz04073u& z)D6o+4Eo;@b|Rhp00=HHOQrU+ck$hw{N|VDYr|;a9mmgz3xw_e81Aw`02sC#SZ-~d zL{DavN45?|CqGm?yrtamPX%I9oT^(ZTZKtts&Ww^1dsv(pg_Q_)jN8}W-N;9H7y)d zav)&fHTnQY3KrXbNV~uUOF`JuVOZPQcUga@_Wfg{7uP;Cfkj7U8$qh>mp1ZUI%ORM z2m;E+7f<@M!uoPJ;2aQ>QotJU{dek#+H#mf&v-n#<<4o&Tv?W^(@8Uz>sm8Qi{pY-X% z)i_e8mp{Xv4WYpR1P_RegtqxV+ho~z0XTvQlPu~GsWR^ETWXP_DiA=$sxn`AkLCLw zR%jD=8Sw)Z>z2KpQAs8fumEOhOmbiW39O}6IZs?5XeS<-6c;>M)n*Yzb=03>-@!=&87ZU zz2&#!wim9`-)Q|^tZZnXpeOJckWtYvfd0>apCdDF<(}a8hZleD=0QC|?!YEjZn$fi z2gADi;L%peOx4IZgbTOOOAO4~b| z!_DdRdkz_i6L5$-Fv^#IzW)o}1Tl6eU-G5ee12K-O2p79OU57}G&-gQc_&yvA&kP_ zU_w=X7!oT>beTkDiHHRwt)nVw?mQIp$^5!i5dh|bEwl(!ejmC400gLDgDp*(-fg^X zIGIqU=?1py7hsmy*~PQ?MmALU9p_vaj#29K>Vf>G`9EuG<}^npM<|VDP9ifkdi|Hl zFWpb>{@FTw)yvtSluRm34O{dHpU3>$l%pQ}Mg82zpUts`SV||-L`;T}saZ`!CNu~k zG(na}0w4-(x?JF3R0Is8B%%qGDoMT6fnH#%EJ7pfYF&CCHc@may2)BX-EF#~|BrTm zZ`j$ws(?TMgl5(9g(qHyQO8k328T*V4X--9+V~;fs74ccAr@qyT(zt@~y^ipm-b9}$@tbWK<+07|sRtS@J-fKUg=(h#6f z2|yHKkUdF4kL3gvB!D2BEJiMU@DP-gWTjS{DG>nx0MQ&)FVc9&c-QoB>mFZ-Lmtd; zAPQ6h1i(ylLma=*gMy%XdBD{@{lDjr_>}J@D~$}-281vnz1S)YFid!(NoAFsQT2;| zD0oYHhO%hvY?qx}tvnz;KW+>qt}UXIHHgFsp%hB1aYEGs*pS=6G6)tH9SR$lh#msS zL>Ot08?_gR`n6sw4I&sTJHamVJlYpIqH*Qwym!9I=fN?N1K`*-O)hSf_nPi!Lu^Q} zSOXZ40qUqA5ZZO^_>&?65CBv*R1fR_W)Ye*V{4cJxe5Wgu%@P7ZBi4?U5HCj*y?S- z>h>QjzVl|O2j+e@n|f=G;J#>%cF(=A^V|eKdUIcJB!0A{oAc`a zCOHtK=3dxLsRP&Tx~|w>;lw=vg19utHKK^8rc>DV8!*d`fL2n{(1OI_MPPAr%W%yr<3g(d=tM3h34 zdql%(u15k00ss+?4F=Sx4(3sr22#ji000CwOaf>aNbSPb^U{oJm?qPda0o9IXB!uLtoR@t!M?Hi9pDCN0cBzP;m@s2jb1z48nAqg9tH?16vq<4z)m)> z!X4-U%i3XaC-so3qy606a2h5Gx-C<`G5I6nCT|QxQ?|{jwP-h78vv_wiqf2?y)Pb- ziPnsuRtN!(R?zxF#IGtez`y|r zn%K{@ePqt$z4se$)5rk&mDHb0J}UImTQ{iO=FK+?clwF@v^riu(OD-XQ%GiZjfmzw zV^i1>?E`D}u#ppIcu6Ga+U-@yWdHyWhj5YCY9o$Stq(Ea2GJ~*#fF2%u$|fNG9nNF zAPhPU>h4AM%O$?Llt(1YC;^~A3?u*m%=|#P3xN#;z|l4{@1<>6`NDfE-+L0jTGKtU zY>3V!`2(a2VjKPQKOMQq+YKsI_}uj8@0KzyM;k=%wdE_iXu~ znz*DTcl#uVD0o7NLn=iVvLIB%bf{brS9SqY2riXc(~YZ<83tPWiQP>a1dB?50I*_6 zQk&Di3J)e68u#;5vud^`Bys=%$93Vda6aqu1=7z1-}0=KGboDa8%q z5KdIP35%#XXTIL}ojV1QbHUy9o--dQ3)hrk&cnVio)SPVqDia_8?RH9a;%hV@6EJj zI0+l@478Jgu+jH6)5i{SU%x9erI$E8{}%kXcb;9FOh}MFjE=o9!UheNo;pDQ0O%wo z$5MH+NTY5|T-;gImi5Vg(5+|~0+2=&hBR#o#xW2q9k+8`FbC1=RLrqnUK?LclN7_D zS-;id-LV+&vMGd@N)AA!I@ZpE*7WAl2s;!wKv2t{RkpUC{l)z>y4p-wM=PZ~{CMyZ zp;Rj>J(S;VbXagCCIpugA|a+}a;TGh$sak_zwFk?yp}>xBuvt(Glznm9+U)$+2>zf zofKEkT86QVBw@uQI2ypVaVtrlg_9UK(oWgLzQ*3WEkoAhcDP>`E?fZv00c*wpwT>W zd+W#7di9b=l}NZ)OpXN3G`CGE)!}R>OIdOU?cR?n*od&mdgD@|`H0QnS|) z)-OMF`Tvh;|KIQ9>IaaBj@^Qe*LC;Q@+C~L20ShsFK!aMQr|3|nk`vM_obx9E?ipL zynqsuP=Lnjx9;8Q0RRxdZtn6DfSJ;3kW@;gHmsgT>KaZF1a6u|VY&YN*wJNcF$Oj2 zl8zGq2&!uEGj-P|4TTfKt~^mf4Ru6^WB+@FRrWGlnr4pG(k0RpTV}Q~Frm0{pe|ol zr+N2;=TDs+cMCs3?tx*BpKz16)imdFCx>kxY%ikuf;Ky|*;Lpqq`m1c2#%lu4+x$| z-#4Y|%xFfBoD@u6S+doZy6WOHh5IM_QHEfIm!P>RfDb3aO!9?2X~Jg z6mXB?rhuI-%f~+h4wnP4;D-PJf?|R>EyRmqm7iK}2nk%sIFk>LE2Yq~)oHtCCIh2U zE`+K9qbknky6E9XK6y6xjyw*Ufd#>k-lF|O(>tqmwkV6X;H^(y1j8!Ryae_=vMWYz zd06lM?gz3ppmMR2kv3v2u zi4aLBI6}#i-HUuJv(hFG=023H5t=a4u6pr|smPD};Mj%UZ)7&`aCDDR>H74&1K(}JcD;2d(n$OW-0B;~v?hhIK*yN*e;qw{$^ z+J@*u$1wX-3_US)B#!msu{Ob*(OpO(WFr#Q&Dh7krX9*9agiswnX?PjzzZ#toucWw zWkDq)b%TRQiBdFSMcUl%M)88_;U^NdUP^(m6oKs6eWN#v2{Z+ZDW^uK1;ulydKU z9?AZfZ#Lx_8|)N@MM2{*ZBjSa;A5#8BXCGa&|){nm~9Lf{;$(IoFF1Gy=0IxhoYq0 z#7yNo6ERgGn?O{g3BGJ`knauJr@8*JF`ox_XNU)ZZ{d>A!gHKR&vfc|;tsn*he93$ zK~pG-HdC^Wpd5|^+bgr`M3=5MLs$=PEJ6Vw!oKiKP(umGlNNL?sa@2(@v>rYQvuOl z^+Rs??qH*1OCE7hog$_rWwbYJ3{@w!g0QNEPm**0Er$Y#Ua_cQP7r9S`V4T$Gf`{e z(4s|5O_#Ev0=O73WNn0IhF4j4R^J*kmDoZ!>fZ7}xTO75x@EeH9HE(mXZVapL>?hx zh$iC=yrB6M8fRg@CqB~ zl}NeblAuKLAZQC@hhrr7 z_VGeNfxy+0gbjhMNqe~ufto0UXoQboNgTZzifYv>)Uw8chK9gs{KQ4>H`#M+6&v-+ z?4%cyv^bl7&^<05P8jDglZ{TqV_{X;?C?q6U1~4arDkmaZ%R^&#Yfb;yH~ZxphHMa zOp8}KtNFZa`tjxI`Tt6ssQrqmRh^nrVBa*~YMeAK9M>BUDt-&c{|f+Ob2h%c#qH0d zeO&Dq_HX*9y^HR=yB7ju{B3bYa82kdA?) z7#-VoaQ*`ek$o83HqfP1v+9;ib=al3**Ok z_jgkcMs~r*We>muDj;sMvfkS@^Q3J_9$6az2p@LLyRzLA?jES_^Oh@97S$x#%u)}N zPPYBttnKUyppWa`>*DG-=R@l}nlIfZpNqwJaelfM0P>rU=Q@TCn`BkWb%c#pB&YHdZB3D1iDoep_3RU29Ls#sD_X1_+j&Jls4mZ zEp_ePMs0PT{G0o7Y_S4l#aH4gQdZd)$^vDn*cN0K$XZXT(gR<;95;rRJe6XQ6cpD7 zZ(JhYm>>l%L<}75f*lw2pdJ-QLZU)}0Lj<@7>yL^90rkNLc3rYx>mBjI+GEEs#Kr` z3|LU9yt_Wb1`ow7V~u>>lFHU8?w~ay5LpLP{wBumT{{;5gDfKu&_J^Or;E!YOtuB?fYI-qw^(0R+g)5`lqzv@ftakdRH` zLxyNcr0ARDGtn8|;y4`0!E)s*%pUElx03b6Ujt7Q@?;xtIUxwhAw+yyKuSGki{;rlsC3cM z>J$h-h6S2%F}6_D9aDE`?&+XjL|?_;+N3P`hUIk9;U5>XwJwz91Q;t|5G(+IB@jt& z1g)x21pq*#V*Lr^2I=Gbbl1#vbY{nB#wtV>YEZg7gVupZhzK!v9e0Z_Tbx*q19Zm& z!Wl!276hkAAej~*DlnSx9Nj~M7P?o;NwDAxT%YDf9Hm7EB!q246H-zUTY5rIW3LUc zSko*Y%qW%Lj9pL#Ek_uZ%3De^;? zury;WwxW2rEt6MQ0vDhT1SAY4cQFr4%XtzD3T0cI}osB#=@65BWVm>qi4ZSZLjt#LTT>56rk+Lgp? z=KZiS=WI|wvFRw`lh{lfZqSpE5MV)?-0_frj4;e4IIqeH@fxz+a~(X7B)_p;AO1Ey zz_#*Rk=2%Lw&u+uTcsWKsrcfY{=)nE2R^9>vxhv|2!aL-%c?gOfE$nYOo`hc5*K{L$FaGYTI+OuqOaO(rnd7ggPA*kw^#to4k{+ zuGtB!IL-;2=ioOd+8r<0ZLscVRd#2-i(H%(cS^SH7uS68*m-uQ_zM1i37EUhV);1^ z7nMl_Y$Z&~O2N3<)skLEcLw#zU@wF0HiaGp0U$5%fo?+ggy9p{)|yCw-)zmRt=O$L zuQkCmm$8Ut8K-_MM~x|-_IW))0rb{z$iaRyxn7@li#DV|O%Gb>xg)BiZ(_iu(q0Br z8{rHf5JeVLOwGPzv{1K}T>!XniaHOm746kx!;|O$eh=Gm3hEB#2mg;#_w zGzft4>BW_rPiJQrOQYt-+|f7#fl9sRg5kr4o9Zb9BM1^aPc8A9NCBjcJQy%vU>(5g zl?tGVm&ylx3Wf_3Fl5w#02jGPve5I-55sJ@H6>rjkRo#fCDHjLDK9N-C&iOQy)D zIu}peD{G>Jmu;VM;VYmkb8&ip;3{Wbt08rvr3nrQeiRcn9p?uGw8YpdKSHsyd>;aG zsKd!LFPeYu8)o@@*`+kN4mvP!=z@Ux=c;}e=B)^8gaA)4)Q^rPKMWXZrtmfdha#tt z2Goi=cPJ;{T`xC1)Eq9mAOHaV>#|6sAaJQC%*X}dnx5Y4w*NFmVgMjiN_2)qyQu{R zN$6+K)f*naz~P0)++xO2h}eJupa6&hfIw)$E^@}knE5VrP*G~cT(@JM-{Zy`BP56g z7XTPG07wM_-oX3|%zp@*hyVr^!U1o57HVG$n>`4Yyy!6*&rl+6qNTPdk9_txtyuE$ z{@r%!Q>H^b`Wou%nWnr;TA!NX`neDv)P%Qb`3{G7fUT~r_fy@}ty(&j33XzbFYoSW zZ$8qB&B4OL;!%v&QSiJ=UUJFx270j+xx@1NvwypwkaJa{>De`XEcLjQ76E`Px-c!| zA>;7wpCWiMX?KdyOA%`sWWW&;rr*^{M}iT zyH~TMKDUzd#*dSxkD*f=+?;S$#-sPJbHlw+fz7}=6a?4c`NRG4%>(vm++7rjxbV5c z22;Eb!nn)S&HeT}V8P0Vc- z>c{4}Jl2ffTp}ViX$qBQ&h_Kf9J}!b(M&(J>fAI2HL6o1+$(Rd&C1R5vXp0M>|W8_zEX zfa6nZ6WX!;&D$^g&#=D|!&{jnr7C$xAK78kNLAXuCpklNH4kzx1}j&T(ah=;YTx@Ozu#oU_%E|dS4vi=-)1c2Bt z-2v=-$!@_Nzx(Wpr@r;UHFCaXc^4AuOU^>Kmb~GieF7-qBmlT2;fMo0Fhdm!(Oqr1 ze2hdgit`I}>Z3^jp>Tk!JyifIuhuVia(!7|=C8<$0{}eK{PMO(+wbik&vRn&3=R;3 zBWVCQ0{Bh)8h<`=Oz4j10|uup{|ENjuePtpZ1EP@1_FSg5NbC6-Z1ah5_b!uLm}Dl z7)CrHg8EWjSGds@}2Z|?Zf9dt51h=Ej1)gd!00=(Vk$6OK=(PSI6RiXlMUQ z%I5$#zy|OMP)g8>dA=RJTY;Yh{eLktXc@LvHfEGP*XrYOZjK(urv1T*`J%rkkkIuH z`iS@b>+jrQ551neDZ!P>pK4(R2GJPZqG6%2(XN4WY;7P z&qflMf-=%m%_sDX+LS9q1t@^^00Fi|j^mw}8Azf7go<%z>ttypGBFiX2uF7MovW)f zERq(9fF-)|b+ut*v;4T~F$w@witCyeAyet)jX+>%S{?O{zz!i4LMvillKZ)1mYB3` z-87T)Ov%-E2%do6gTBz-^jf!jd}5DZg-}Y{JrCpyx(RD*I@*Lq*Qk7?=2qAMS5TrZ z%5nj$#7qzy#jFtl5-jc03=E~dP4*G}CGSsZp$gULg5q_>N}I%ZxEVb~)jH4;5Q;}j zoI@y7wJ-!iz{$;)n=PjxKp21#Qa*Jp7ero#6KA`-dbb|w*(cvQup8ZN1Vjtzy!ngE zL_OF702BB1?+dEH001Q~oDDB;bUtjoV80$hm=rd%u}6j-M{%IsMtpO;-#qKRDI6Rh ztjJpW;scJ&qxbB>)N5-#HThMD`W2hcau4SotIt}C3z*F4T|zT4;RsY{@w22PPEG}5znFb?eIO99SiAtxx(Aj# zoytl<~_OCalq&cRlv3+ee>V_0K=Q`DAeU#F%&!1VDg}TAbdT-&Sq} z0rRa7#{TmFRkVN@5S2-JW!?Pu-EuQOvE`}<_a!v4JaJF9$@iK5A(lsMi&xwe-u_q> zB0X_=Rg?K)p=dOtw~8Y%LTZuZ(q$KIQX^N@0x#aaiFY4PR14$;J}p>b7(!q--EnOm zfc~T`e#_Epw%Tj#nH|}>e5>}_tZB$Zs-$fZSblPNeR&`9MdXASh5^8M3R>vUPOx*a zO=qt4S6xm%4+da0-)nK9vE^zu2+et7=k+qLZhwnM_*@QR1gBNchfH>r^h4t*Y!W~#^F86y+r`7U*p!*uu1g> z5DYhN!0(bD!J|k(fB>%KQBvM^EY1E^&+4PMK1u!iYH^r)&0<-|YK_Ls^&Zjs z%Ps>Q4FcxyAN%lD*5-r)-V?f9{>XzzA3hMp5eHAu^2oaCUh*{*KLxgce=fGr^WJdR z^f1*zHY%oYtuW0I*I%jSK`S0~V)C7WuWht@S4T#Rac_S7Qy(@Lt!E&w;@1KJ*Kk)i zCam4Y>;*_Znnz1nWT8s8TYdfAn-RLY9Du!x!4%fbfPA0lWW z6o5MAuamn;#*W&Quv|7j0iY_&!u zNX5s`x;?rmK}1jSU%1UDmdkb%m_K#vFYO0CulyGi` zH_mX*1lO-Pw27Qi^JDwe9z)J0JYP_Ii{7@f-}mqC&8$Gyd`S``+K}JHz*Nn*ZJi0C++)$)itwTWvH=f)@vTZ^qk= zTgLZ-131*)XiS%3MpxL;Ly0~P+ijF6rf8^IqACcvumda?c`PzQQ9dzjS0l?o5Cxj2 z;Mb!1($qubQELrdlKOBYRo9E-a4c|_&39hDoK}`?pZKhV7!wD?u!?53v)nyt?1?Q6+r==fAWB|cA$DUvo{|9wL`?$gNgKi#=mngS;&-1r! z?%%ujZc)S~3gWXvJ?`m$-@~0-^JEwR2n4F(r1g!!6+bj9!=anw_EP{riiUQZNo6De z#1C=LX1CbQGWlP4%!|kV?XPxo z?0P5P6xA1;?&!pW4Q)^gNl16lx>Qm3INFKTd*2>4M13jn+z zKmY+lOgMc5#3m%HrctG+vd7M-ryX5$ubz1;|JG-(E`W;MBHD+njdEqw!NV1eMOal= z=p>%@BDU5Ma<#fhsjU5%i~I@Y4*&qP3jm)7{&C=c3!G+`rT z`jb*Olb!HteDT6w!2b25={Kci?ERJ>olgW7A8z-=W)KJ%1cWIl>Uy_)6`XG9S^_{8 z089=o8LMvHrJ8)H1&RUyLr4K20SGa0@&+KAXG#Qvq%c!|B0c}7{v2AmHetAlrDqnC zrYa_*o)*@+k{=B)HKwWUrxwwh{mP6l0C+ZXQBThQF}448!@nK)59cD$4NO5M6dj09 z@hBe9l#CpsCBHseT|9g|brx^bPhKGZ3hsfca>)Y+4m`jC2av01*cHU(?k9=QKfG~xtg9mb%3{q+Y zeeCa)%N=zsBjJI|aA1$!PN=wm`_5>~CBYG``mh0q{E~H>Q%wvz3pEz<))M0U?}L*^ zYtv!a4tC9nXPzS|CjaerX1>91Tpf@&PU*P!LeWV!~PL4Dn#iH?eU_` zKR$o>Lz?_qJo|Y3i~x;ir**o%r|6e}@n9Aw1+m;Xe!iN3NqbCsC*d=;|K2ihtMmKX;EFjIq{j z?i)X)J8yeN2Y|spc-3DlxS;!ZT(XH4D$pL@NFXWz0LCkO*Cy-qtplAU6uw64A&9*@ZLjzzyq)-nqa~OHZjba%# zZyk#d?W4@!lH)waO|HrZ;PC-0G#Ka8`2YY304y{PsLB%-Xn6W{R(_|P()gLb*x6U< zt{@l$z+lGw4;68zN+D_}$}Nf3$Plx6c~m}%)(93rYBBYFR!$WMP_je`)5S_zD;0wQ zpirag0khjaEcZaGI!}0?8iQ9hYtcMQgBl|OLoEcOPqK#W2x=9lA-1KJG1}i7m&JEu z2$Jaw2ml+rqya#8S1-&jtSSn1&i~Ky$}?a2N^3lRmY?qY8vud=fLZe8#)L#T z91*8zn29jCVGsaqkfRAyJ> z8N*(UAskf@9Kz6GE~lr&j=OIQ-u?W&=(IP8ADA#Np`Y-g?mLyoAArIDo)5Tn)t(P5~g| zLa0@`Ej!KBjak)coYDJ<sc{6NZ&V_bM7xRIsF$jW|#N*9<{(O~dpJ zj&GbF|6m-Kz`g*?2+<(H%-}JNrm)}L{*6S~9EQcoSL5dgzrV7CVJHs2di};PxeK67 z!c%#u6d;zsKme$|CfVgT*imyI!f z|Mp_0kF~een5zHgzXHA@tGjr*A!~Ko-3es|9y|aTe!yh|2un=FNenUaxKq&K?M4y~ z0LTMg;ORI&yM#bl9~6)SRF&Zjrx-_rQO_8xg>S=zv5oFMU-=k}rFt}LWeqWUyLhvv!EKJhP( z?JvNepXe`)oL~1IX%dP0+6Tg-00U-3viPoV&-LP*oc_GvC*1$gsIRV`-4y#l^_>-J zC?Nn4rm^%%9snpgpJm(AGrRUv-KRAV^;8Ii*ydP#0st?2$tBkn2op#E7z~LV2h_2P zi1Z9&wWrYo%M>Br#LN+AGFe)};^56-O<} zTEQges36q0o$Eg|v)}H0_i_JA+iO>fT?8QjNQn#V*wHfp>6;%IRA|c;%X*1Z!Jw?# zA#0Q!Wl$ATu!?oslL8P(Dg*$>w`M5I!@_22;nH&m0b;@w98YRm$;&%=A1a6B8{;R5 z7@+w>4@G^y41nF`Q&AHW=8AC5>7~4v@`Zq9$I^q%i;%pk+iFCc6wrDwY!@fDrXIEg}$z z02bJ%IA|PWO#lF?FgjdlT+9%|kg>MV8SBJS&NZ{}O#tM>J9WQA66HJN(*PbI{J~?w zD_t}#AZQJ<6gQ+3UW<&#D);!fp`SO>isokbw9SV%A5^DqZ`*vl+b0c&C&zlD4%M|n zn$WYybGt)HdrrHlJ;RdXehUj$Of-@3&1p)IKm#g4kfZ}mB2dJF00@W;As{HCPUqeO zs~`}hTH?&SQk4L0F)%5EdZV2P`^Tr586g0`VT(_YEH01;KSJ;Y1B}^RMsQXv!`EL@BI{~L7&y5kVo_r70AP{?K=)L%V$BI!-B|)*U^Lc{fsh)L zQq;CEnKYv)0s#GzoGF_>2|QsJ=h%J0*kjhvJOa@zaeIj0>}q(+!*f<&Hq1-9@>RSb z?h3kR*gXRZtFQ@O5MZH+D{}uxo}Xy;hw%e$hF3|o?N<^7@M76dH+EJVA|{WiL4r&X z5=7=SR-l4~(O%kL%JABv-Mb;kiq~? zi{}mRZ%|;n&D~LSS=qS`6MpBgKZ&bL(^)69f~iGFveH4bsL2kdu>}3z9}jdkMS@I= z9s+_H9X1FNfYER5FGSb^NkSouq7|KYI;`BnCSbrGG==&MTLhUftg5C#)`?F;e!fHp zhu{ap51TLYr2*`)%PyNyokk%bSZtTL%A>CXU(?NhyX~)VxUdd!nb=}NUAcbHUaMSO zd1u%#Mdvt9dYfLOstRRIR!$|hZPRjo^7lLqy3Mv<{cPg<-#pLeKuV$EexRYgDD!Hd9eJopyhq;T!yXxKjJHh#2L=Ee#tu={?}m!g_x_+}hhH@> zHZ#Nc>K%OpRZi{Db@YwU^(Ng!lOEL{o^TGHlVpK$>0<4`r?-~Nv-YC2_tAgoxqUhV zZ=1t{SO7?Q=lCiC0|CI{=z+G8N+bYEq$s2e0U$w_43h=HqEII1tflosPwnT_ecz-> zu=c(=kfNYMRlx%yV;~TKKxAYf09kP<#sn-_8jN9?8#T@nsXREkR_+(u!&m#%M+6v!?)NG+Y3>zS_B)-XThJ*%XG)S$qT<;VsI&`>1U0z8?2d-K=b zaQ^U9r_NdrR=RfV8BT7?+^ zP&^hE2we(SAJT6LFg$=@+2nzbf>WO#Q%WxyV39kl3W$$*FhC`JcL5S8|{)`o5*} zSd`5u6^sH}l?i$RfhD3c>`7Aa<%au#*2AOx?=atpIC&dlNe4?7KM^DV2s0@}6pzNZ z9PMDT49e7h(V8TgZk4HHCRGXacDi3jr{IQX@-fPtF{8>%6gXYP3DzuRd;|ajGLWfd z0W^?gR3g(eB~?X%m$3>W0y04g=N?~WG7)5QB)$s$C8W~oR-fQ`88m*h9cQ~ z`0nhvh;dnDktp;4dGH_tKn2P&pH3nc`NsOPSPgbmIJy(FyS}!ShUK|3To5*Sg@qy$ zAPNjda&W2z>AJNfG?^IN5u9UmSRw-)Kh~F zo6IFr6jR$t&p{Ol4FeADDCFKy7Kua*l{30d>V7QkII>q)ivvJ+^-aa1FP_d^LguC5)^Nc`)HjEfFG(_1)0w+of z?>08SoOl1(@EJTNq8U-`)eOI{-o}&%DWn{${0kjui}Fdt2>eiDn)HNs%eO9}vX#?e zj(|XtIpAh=@t%)`li3FIWj?QZ5q(>;;C8V3dbV6ws$*%6){dzhlilSJ*JTB*Go)F2 z-M+Thcb#gIGr2J`023;RBY=#fYZ!;1dbW5qTHOd<2tQ$QvpI->iijOmwa|DD z$t-t;k4c(`IjLgf4}c;siysbtbv*M2?A^PPV;y0x9)@KCi9|vMlz6**di|?nc5w2? zr`h4@jeAQmuVY+t?L4N@YO!-FvpH?sR&!kb%&iFIn;xt3wH*M!!<@fc#yGVX-mzsO z$P;g^0JuO}hiC(QDcf=ut-c*Hiyo*GIH(VhiPjD|SXTpTW;N0S^%_atTkz*0^kB*Y3o z&L)h86GxZ?*)_jB+cBaL1SxUcyEkmOuT}*QS0sm!O(IKoK$g|Yr~X_7;1-~@IQ;X6 zy8U{w@XE)forkIr3=FAp>JVvCh$j=8w0O9(Twdq2)#$xMezc44fiuX6^mCV-Be`7W zMDP88A-Oq%pMz-P>u%%qMZf>{Mjx%*CpGM9^7EjnVA@ntJ&bMWfojR?MvS=Ce5uLw zHAT(4lBg4vYO1h6aH%N{tnia~M;l0h6Oc)|K6-S%dpATh6oTe~gIg+sak#q_a#*}i zU92g#3sSwD%YPl?8aIEJdeyV^zT2@&o(5Xt?n=cX0Tm`W1)Lydq+U1=<;L1;3^Xwi zpd&s5nN>AUk=%#bRPagWUgIHf1=;0){j~P(&GW(T*UicoIak(;wP0hxdX~CCQ19Rk zl_)|{(41*LSGV*-_z0Jp-vnYG0F>Za`3bl+r<>PLzw+_J>|02*yTAzulIZ=LefQ1b zhzT>PO;tm^9f(zPLSscKbaCur^(c+Aa`Si4mH%`?Giw}EbFE((9(nO1QjWf`HHsEZ zaS!KlMa ze5db+9-Bz|D9;CZX3VdHXskks;0yea+(iFM0oQb}{^6)80*V-I3OyL*a(w^&@p+Fe zQX+~bn&~{HJj$$`?~|WwduB-jL;~Rs zkpd@Y^R<7v`HBtuMP`SN;Vf#~U`^5!ZKauGh=Nc;1Wf96ZK3y5fi2k4NVArR$_HAt zM%NfsJVGKUzzIa503sn80{|3VmDy0APU-zIHeDfHh$O@jq&>E~x$w({1<(aEF+Td{ z4ZF0mShtF9$&xIH0ELy|Yf_IQl@I}#ly#8OGvV#s|B+iYcp!2> zgOhKNwbN-`d+m8y%xof4in1bYwDP%DHo$^}7~p8_-0uT6s=+YKXG#v$oA7mcB2QtZ zozz4g*ECh*-MMSJhyD)!73Pu|;H4*yBLPBmfBrY`GjzIX{R><2?Lq__^& zO|MhjE5dodKyT~5T#*K;&Z^jJW9LJO#aqkvjzh=%v^8IgU+Npx=wY|UBi5$dkm!R0 zeRg;dZ@uwiI=h+J0(N5Bo~>WG-9VrCF?>db_bQ$p-~FuY;Zz3{+A`A#*36jl7%7O8 zD_2F5O*O0Oy_+S_ZVLGzs!GD!AjbojYSWUpb)&P5%=V_<0gQT88$tb4^%XfD1%BNk z8kJYshUTqk?YujOv6stnX^UM+v4{28oiDikoK!wOa~d<`@eM|j7qqK}QMxet>%oDn`Jym^lMfVET{&Lr@l_T>tO2%-Ra zLV*S3?e4a15qqd&kN_bUCP6aL++*b8O?P)b%KEwjZ|z@V<7 z$f0LIhz6_*<|<8t76MVsV=Rq`u*^-f=b+~!iktO|6~3| z<`kr;j3*QSyJyVgZZuq?bFM$2)95;?R+XYGXiL|GbH>FZw}y5NTtf@@>Mt}7ootXH zB#<}_KGm`#<-I`(6{8waUEo|HGIHiWA3 zQKKM*E?P%eJ=#2=0Ug?{c+wu;*)A^Ti?+v4f{#cyc%dHNYIZ{o>HtwuC3V0V{*QU0 zi!e+=wV&>b!&#IeU1+gY^F+TlzeBm&$t{BhUp6~vei~B=Af$%6adgL^ZxlGkYytow zO_EW>mwV6)O$Z(5DTtscfJK_Z9sZcVAUNlr7;uP@1CdxebQ@Y0B>@PeMnC{{V2is2 z2}Fb=oS#H0|v^NQ9vPFdOpx49%Jt;F2W_ zKt)tyLSfV3iH0}&?i>CtaUtwWN|)DR7NH2>f95ZG7(t9kKrRK42_keU`jJ*CY*AxM+>JZ8&u(M!qJw7FIyhTjmc0D(9mk6{3ifKV?efFRVZUQ8I6&=8OkAbO{z+cJG|_M(6eMTU?%VpIu@$eWO^$G?L2-y z%G;xVoaqw{Pkrh|$pSu{`iD1cb!8t7b}Mo)6{W5voJvypD(ZG2YUyG4Q4|}?Pm)9A z@L6?|HMoHG;oylD41f3w=zPk#`c3)^AONGT#e?nu%|@bu5Q2u%pb4R|9W>AYP+1TJ zB3g$DWx7s&yM%K!Jq}LGJB6M+IHEW%rZi>CZEni-%cTGgs+^K#^JU_ZtwU>I%VP`M zh3XqPs?I<&dSYq_cU!%$e{PmP`|`wVV~#d}#UDJ&KMH5_z zxMb1HkCX{MQ~5L-%-aw^0K}0=#RBUOEcgGzwr zGi=$Fg7c!GG{Xa7pw{D}jGxJ_gE7-QOX_O?x`TW}`j*{=)%<&v27m|2#}t?mWi<&5 ztw3AYI_ZYpZp>(MB1Z1Q9g@*^neFQT*V)ftz!p6=DZF_N|MBoG$KX0IebILgZ z?*iQ)ShLUR_Kz)ka)jnYX$pm?iD({3gJs9~CD-iZ)wcG>?a-a)h;8^+RQs|FJ&Z5- zzoyl(bF=($ap0zJQUI@f-lbd+5=%sZKnRg=X(v0l)gm0A0gEJX)K)3mr|iU8bOr@S z)X-X8#F#B^(nEW&ZLuTYqIpuMsVphY8})hb$5*$%a%B<#83N7dalO`%lKO^4z=OUu z0H83qs7rV%HnNKcL|&#qU|8o&g)k2C$qP&(t+9CGLNSfxui8}YDA`4)Z^!?h?v3Y^ zkL8iWW7$468>1P74a5Qh(qN(nZRQB42F!$rY!Db_iCyXN-o1B@u`K zJmUa~1!1a2@0zkspjAkZSZ80!xEuj9NLjCRHd~#-}`QE>bB;snL{Jb<1R8Uba@Ekp9Lz$w{v;ebpKb1I9m-0(a!$ss*2 zm}^{Yd%m^P5mfSa$lD@` zgqRDqm_W42h#q)+N-2iZ~%gWiV+N(4CK0 z0R+JS#@>t9Cn+D%0-CEK3PU?)`JtSBZws_BJ&&%!Nh4u?59?xSCvhJ0sa3O6>Whl< zqMCEH8mdLeVf*#lGHzZfM_{-{eSu!YI-6(JrVv7fk%$n8i~xWU)~Xl}6kzVQI09`5 z1O-Y@M`dMC81eico7iBs#82(;m9Riymr9PF#$0GJW^zaE-HtavBVyhnSmzS>ygi3c`qyQIfb7F-<1$>-h6+g-KT&m^cz%CnA4sRg7K zkVr@w3^f*mTZVbq`1vcsX?F%7u1Q3|u0va1j7_-OGIT{qJv^&5BghJ1q$&&|%%M!q z0I>l<1&$$05{^Z;y|}B1g9La$>L9H;au5Xo3Y;iWH;RYVxQF#m!1^bkI-yL+Ld$6AUIZv&asb`~PvA*SWSbQM zBZU)nhyvU7jjct$E)P6k&lvcJ43K|>r7b7*!b=s4k@9Q@9)@RP3ZY{GQNRRf1R)^F z)+A3S6hIRgX_@ly2S3sI@i730))V-k<_c8@F8l;CltR1%J*()Iy5vQ{CWVK90}v^V zrdHbc^y{vB?bIE^3=v1@lh8GCI$_u49NoK|#JrIMsTNQ{Nh)P&cud-tB1p(lga!0L z@RV9LOZWHU9juV#yZ1jL&K~h;tx>aA&N*u1`=}SSNwd`z0Q7&u2puT6V1$fx`b0RR9X$W)mvmlxC$mM`qiJpZp@k!pW={n+`JD=Y4M z^Q?CAAkV}s$zrA)NQi+`ApOYDoxO?&x2UWcY5)YNlVG+*QAZ9YltwL%Zd0SEH1+#S z!CY7#j_Driwiy*pxIdlLvLQzJqGx_+@vlFi z*FHllPv|}O2te(Mt^fybpz^6hc)aWwONX%ScU-fIsmM`Fk8^m`T(4uyJM;WyN_0b~ z-|x`+D=;$G_y6Y&8D2jh!h-iXh1djU_pCH|EZVN{kw{@tHNogaVk=933UxQ7oFOUo zZcl36HSH4*OoQ{7@dRewYwSg-`&t3;2g{T{PaV@Nd%gXCK*a(tmkSv|2qF=HNOm3a zmJeoQ)aSr2_t|Ld#G zp1u5!ZyR!nWP83QQ%$2(>0*bgseLZTc1Xq5@lVtW^zryDaa!%O*DO zspL0%dU~PW@D21odsp$VF9V-} { const signature = rosterSignature(); - if (signature === previousRoster) return; + const anomaly = configState.anomalyId; + const prestige = configState.prestige; + if ( + signature === previousRoster && + anomaly === previousAnomaly && + prestige === previousPrestige + ) + return; + + const rosterChanged = signature !== previousRoster; + const prestigeChanged = prestige !== previousPrestige; previousRoster = signature; - - layoutState.rebasePlacements(configState.buildingUpgrades); + previousAnomaly = anomaly; + previousPrestige = prestige; + + if (rosterChanged) + layoutState.rebasePlacements(configState.buildingUpgrades); + // Research does not change which *tier* a placement is, only what that + // tier is worth — so the board is re-rated rather than re-based, and + // `setPrestige` re-scores on its own. + if (prestigeChanged) layoutState.setPrestige(prestige); solverState.rescoreResult(); }); diff --git a/src/app.css b/src/app.css index fa7003d..5443abf 100755 --- a/src/app.css +++ b/src/app.css @@ -64,6 +64,7 @@ * idle — the board only: this building is doing nothing. * danger — the board: overheating. In the UI: this destroys something. * warn — a limit is reached, or this board is not yours to edit. + * benefit / drawback — the anomaly cards only, and nowhere else. * * The two board readings are **reserved**, and that is the whole point. The * pad under every building, the pulse that breathes it and the readout in @@ -109,6 +110,136 @@ --danger-bg: rgba(255, 71, 87, 0.12); --danger-line: rgba(255, 71, 87, 0.4); + /* + * The anomaly cards' good/bad pair, sampled from the game's own "Choose an + * anomaly" screen — the two striped panel fills and the text on each. + * + * These exist rather than reusing `--status-ok` and `--danger` because those + * two are the **board's** language and stay reserved to it, and because + * reusing them would not even have looked right: the board speaks in a + * saturated accent on a dark ground (`#4ade80`, `#ff4757`), while these are + * muted *fills* carrying near-white text. A player meets them as panels, not + * as status lights, which is the distinction that keeps the reservation + * honest. + * + * They are matched to the game deliberately. This is the one screen in the + * app that mirrors a screen in the game — the player has just chosen there + * and is confirming here — so the pairing they read a moment ago is worth + * more than a palette of our own. `-alt` is the second band of the diagonal + * stripe; both panels have one. + */ + --benefit-fill: #2d543f; + --benefit-fill-alt: #386a4d; + --benefit-text: #e5fee8; + --drawback-fill: #6b3028; + --drawback-fill-alt: #813d32; + --drawback-text: #fadedb; + + /* + * And the same screen's *selection* colour, which is the sharper exception: + * `--neon` means selected everywhere else in this app, and here it does not. + * + * The reason is the same one and it only stretches this far. The anomaly + * list is a second view of a chooser the player has just used in the game, + * and there the chosen card is ringed in this green. Two selection colours + * is a real cost; showing a player their own choice in a colour they will + * not recognise from the screen they made it on is the larger one. It stops + * at this list — nothing else in the app may take it. + */ + --anomaly-selected: #79e097; + --anomaly-selected-glow: rgba(121, 224, 151, 0.18); + + /* + * And the card's own ground, sampled from the same screen. A purple that + * exists nowhere else in this app, for the same reason as the two above: it + * is what an anomaly card is, and this list is a second view of the game's. + * + * One token, not two, because the game's selected card is `#221438` against + * `#23143b` unselected — a difference of three in one channel, which is JPEG + * noise rather than a design. There as here, selection is carried by the ring + * around the card and not by what is behind it. + * + * `--text-dim` reads 5.0:1 on it and `--text` 13.8:1, so the panel's ordinary + * type needs no adjusting to sit on it. + */ + --anomaly-card: #23143b; + + /* + * The ground the cards sit on, sampled from the dialog behind them, and a + * bare triplet rather than a colour because **three surfaces wear it at three + * different strengths** — the sheet at 0.97 over the board, the docked panel + * at 0.88, the readout at 0.94. Each keeps the alpha it already had, so + * selecting an anomaly changes the hue and nothing about how much board shows + * through; a token per surface would be three names for one decision. + */ + --anomaly-panel-rgb: 56, 38, 80; + /* The opaque form, for anything that must not be seen through — the roster's + sticky category bar is the one such thing inside these panels. */ + --anomaly-panel-solid: #382650; + + /* + * The whole `--neon` family again in purple, for the inside of a panel that + * has gone purple: Setup re-points every one of these, so its heading, its + * active tab, its selected island row and its category pills stop being cyan + * along with the ground under them. + * + * It is a **lighter** purple than `--anomaly-accent`, and that is forced + * rather than chosen. Cyan earns its prominence by contrast, and the badge + * purple reads 3.3:1 against this panel — fine as a stroke on the dark + * collapse handle, unreadable as a heading. `#c9a5f0` restores the 6.5:1 the + * cyan had, in the same family. + * + * Names mirror the tokens they stand in for, one for one, so the mapping in + * `ConfigSidebar` can be read straight down. + */ + --anomaly-neon: #c9a5f0; + --anomaly-neon-strong: #d8bdf7; + --anomaly-neon-dim: rgba(201, 165, 240, 0.4); + --anomaly-neon-line: rgba(201, 165, 240, 0.5); + --anomaly-neon-glow: rgba(201, 165, 240, 0.2); + --anomaly-neon-bg: rgba(201, 165, 240, 0.16); + --anomaly-neon-faint: rgba(201, 165, 240, 0.18); + --anomaly-border-neon: rgba(201, 165, 240, 0.3); + + /* + * Type for that ground, sampled from the same screens. The app's own text + * tokens are cyan-leaning (`--text` is `#e0f0ff`, `--text-dim` a blue-grey) + * and read as the wrong family on purple — the panel would look like our + * chrome with someone else's background behind it. + * + * `--anomaly-text` is the game's `#e6d8fc` untouched, at 10:1 on the panel. + * The other two are **lifted** off their sampled values, and for the reason + * `--text-dim` was lifted once already: the game's secondary lavender + * (`#9281ad`) measures 3.82:1 on this panel, under AA. Measure against the + * panel rather than the cards — the panel is the lighter of the two grounds, + * and light type loses contrast on the lighter one. + * + * These are not applied by any rule here. `ConfigSidebar` re-points `--text`, + * `--text-muted`, `--text-dim` and `--border` at them on the tab's container, + * so the whole subtree follows without a component knowing about it. + */ + --anomaly-text: #e6d8fc; + /* `#94a3b8` -> the same lavender family. 7.0:1 on the panel. */ + --anomaly-text-muted: #c3b4dd; + /* The game's `#9281ad`, lifted to clear AA on the panel. 4.7:1. */ + --anomaly-text-dim: #a091bd; + --anomaly-border: rgba(230, 216, 252, 0.14); + + /* + * Two purples for controls, and the split is a contrast one rather than a + * taste one. `--anomaly-accent` is the game's own badge purple, used for + * *strokes and glyphs on a dark ground*, where brighter is more legible. + * `--anomaly-action` is a *fill behind white text*, where brighter is less: + * the badge purple carries white at only 4.0:1, so the filled pair is pulled + * two steps down the same ramp until both the resting and the hover state + * clear AA (5.4:1 and 4.8:1). RUN is 12.8px bold, which is not "large text", + * so 4.5 is the bar it has to meet. + */ + --anomaly-accent: #9b65d4; + --anomaly-accent-dim: rgba(155, 101, 212, 0.45); + --anomaly-action: #8450bd; + --anomaly-action-hover: #8d5ac6; + /* A limit reached, or a board that is not yours. Never a plain action. */ --warn: #f59e0b; --warn-dim: rgba(245, 158, 11, 0.4); diff --git a/src/lib/components/header/GridControls.svelte b/src/lib/components/header/GridControls.svelte index d585470..5a8381d 100755 --- a/src/lib/components/header/GridControls.svelte +++ b/src/lib/components/header/GridControls.svelte @@ -1,4 +1,5 @@ + +

+ +
+ Anomaly +
+ +

+ Pick the anomaly your current timeline is running under, so a solve uses the + same rules the game does. +

+ + {#each ANOMALIES as anomaly (anomaly.id)} + {@const active = configState.anomalyId === anomaly.id} + + {/each} +
+ + diff --git a/src/lib/components/sidebar/ConfigSidebar.svelte b/src/lib/components/sidebar/ConfigSidebar.svelte index 1fd2eb7..f87da06 100755 --- a/src/lib/components/sidebar/ConfigSidebar.svelte +++ b/src/lib/components/sidebar/ConfigSidebar.svelte @@ -1,10 +1,19 @@ + +
+
+ Research +
+ + {#each PRESTIGE_UPGRADES as upgrade (upgrade.id)} + {@const on = configState.isResearchEnabled(upgrade.id)} + {@const level = configState.researchLevel(upgrade.id)} + +
configState.toggleResearch(upgrade.id)} + onkeydown={(e) => onKeyDown(e, upgrade.id)} + > + +
+ +
+
+ + + + + + + {upgrade.name} + + {on + ? bonusLabel(bonusAt(upgrade.bonuses, level)) + : upgrade.effect} + + + {upgrade.description} + +
+ + +
+ Level +
+ {#each upgrade.bonuses as bonus, idx (idx)} + + {/each} +
+
+
+
+ {/each} +
+ + diff --git a/src/lib/simulation/simulator.test.ts b/src/lib/simulation/simulator.test.ts index 403e3f4..4273b2c 100755 --- a/src/lib/simulation/simulator.test.ts +++ b/src/lib/simulation/simulator.test.ts @@ -249,6 +249,7 @@ describe("hand-placed scorer matches the solver", () => { effectiveValue: VALUES[id], energy: "energy" in tier ? tier.energy : 0, waste: "waste" in tier ? (tier.waste ?? 0) : 0, + baseValue: VALUES[id], }; } diff --git a/src/lib/simulation/simulator.ts b/src/lib/simulation/simulator.ts index ae77c9b..0add2e3 100755 --- a/src/lib/simulation/simulator.ts +++ b/src/lib/simulation/simulator.ts @@ -1,7 +1,7 @@ import { buildIslandContext, type IslandContext } from "@reactor2/solver"; import { simulateIsland } from "@reactor2/solver"; import type { Placement } from "@reactor2/solver"; -import { effectiveAtValue } from "@reactor2/solver"; +import { effectiveAtValue, type PrestigeScales } from "@reactor2/solver"; import { unscoredPlacement } from "../data/placements"; import type { BuildingDefinition, PlacedBuilding, Tile } from "../types"; @@ -65,6 +65,7 @@ export function simulatePlacedBuildings( grid: Tile[][], buildings: readonly BuildingDefinition[], placedBuildings: PlacedBuilding[], + prestige?: PrestigeScales, ): PlacedBuilding[] { // Copies, with every derived figure reset: this returns a fresh set of rows // rather than writing through to the caller's, and a figure left over from a @@ -97,7 +98,7 @@ export function simulatePlacedBuildings( if (t === undefined) continue; // Two placements on one tile is not a state the editor can produce; if a // save carries one anyway, the later one stands. - placement[t] = effectiveAtValue(def, pb.baseValue); + placement[t] = effectiveAtValue(def, pb.baseValue, prestige); rowOf[t] = i; } diff --git a/src/lib/state/boardHistory.test.ts b/src/lib/state/boardHistory.test.ts index 2eeef7f..9902aa3 100755 --- a/src/lib/state/boardHistory.test.ts +++ b/src/lib/state/boardHistory.test.ts @@ -16,7 +16,7 @@ */ import { beforeEach, describe, expect, it } from "vitest"; import { layoutState } from "./layout.svelte"; -import { ISLAND_TEMPLATES } from "@reactor2/solver"; +import { ISLAND_TEMPLATES, blueprintRules } from "@reactor2/solver"; /** A hand-placed building, scored to zero — `recalculate` re-derives it. */ function placementAt(x: number, y: number, buildingId = "generator") { @@ -275,7 +275,7 @@ describe("board history", () => { }); it("refuses to record or undo on a previewed board", async () => { - const code = await layoutState.exportBlueprint(); + const code = await layoutState.exportBlueprint(blueprintRules("none", {})); expect(code).toBeTruthy(); expect(await layoutState.loadPreview(code!, {})).toBe(true); diff --git a/src/lib/state/config.svelte.ts b/src/lib/state/config.svelte.ts index 986b4fc..87d0a35 100755 --- a/src/lib/state/config.svelte.ts +++ b/src/lib/state/config.svelte.ts @@ -1,27 +1,79 @@ import { BUILDINGS, + DEFAULT_ANOMALY_ID, + getAnomaly, getEffectiveBuildings, + prestigeScales, rosterCanProducePower, + type AnomalyDefinition, + type PrestigeScales, } from "@reactor2/solver"; -import { buildingStorage } from "../storage/storage"; +import { + anomalyStorage, + buildingStorage, + prestigeStorage, +} from "../storage/storage"; /** - * Which components the player has unlocked, and to what level. + * Which components the player has unlocked and to what level, and which anomaly + * the timeline is running under. * * This is the roster the solver is allowed to build from: `buildingUpgrades` * maps a building id to its unlocked upgrade index, and **presence in that map * is what "unlocked" means** — `isBuildingEnabled` is just a key check, and * locking deletes the key rather than storing a flag. * - * The catalog itself is not here — import `BUILDINGS` from `lib/data`. Nor is - * the catalog's selected tab, which is view state and lives in `ui.svelte.ts`. + * The **anomaly** shares this class rather than getting one of its own because + * it is the same kind of thing: an input to the solve that the player sets once + * and then tries against one island after another. It is not view state and not + * a preference about the app — it changes what the rules are, so + * `solverState.solveSignature()` counts it and a stored solve found under a + * different anomaly is stale. + * + * Neither catalog is here — import `BUILDINGS` and `ANOMALIES` from + * `@reactor2/solver`. Nor is the catalog's selected tab, which is view state and + * lives in `ui.svelte.ts`. */ class ConfigState { /** id -> unlocked upgrade index. Absent key means locked. Persisted. */ buildingUpgrades = $state>({}); + /** + * Which anomaly is active, as a bare id. Persisted. + * + * Stored as the id rather than the definition so a save written by a build + * that knows more anomalies than this one still reads back — `activeAnomaly` + * resolves it, and an id with no entry falls through to the baseline rather + * than leaving the app with no rules at all. + */ + anomalyId = $state(DEFAULT_ANOMALY_ID); + + /** + * Time Lab research: upgrade id -> researched level index. Absent key means + * not researched. Persisted. + * + * Deliberately the same convention as `buildingUpgrades` above — presence is + * the unlock, the value is a 0-based index the UI numbers from 1 — because + * the two are picked with the same control and a second convention would only + * be a way to rate a board at the wrong level without failing. + */ + prestigeLevels = $state>({}); + + /** + * The research folded to one multiplier per role — what every roster + * resolution is handed. + * + * `$derived` rather than a getter so it is one object per set of levels: the + * roster hand-off and `App.svelte`'s re-score effect both compare it, and a + * getter would mint a fresh record on every read and never look equal to + * itself. + */ + prestige: PrestigeScales = $derived(prestigeScales(this.prestigeLevels)); + constructor() { this.loadCatalog(); + this.anomalyId = anomalyStorage.loadAnomaly(); + this.prestigeLevels = prestigeStorage.loadPrestige(); } loadCatalog() { @@ -63,10 +115,54 @@ class ConfigState { */ get canProducePower(): boolean { return rosterCanProducePower( - getEffectiveBuildings(BUILDINGS, this.buildingUpgrades), + getEffectiveBuildings(BUILDINGS, this.buildingUpgrades, this.prestige), ); } + /** The rules this timeline runs under; never null — see `getAnomaly`. */ + get activeAnomaly(): AnomalyDefinition { + return getAnomaly(this.anomalyId); + } + + /** + * Whether a rule change is in force at all. + * + * Asked through `activeAnomaly.rule` rather than `anomalyId !== "none"`, so + * a caller never has to know which id spells "nothing is modified" — and an + * id this build does not recognise answers `false`, which is the same + * fallback `getAnomaly` makes and the only safe one: this drives a signal + * saying the rules are not the ordinary ones. + */ + get hasAnomaly(): boolean { + return this.activeAnomaly.rule !== "baseline"; + } + + setAnomaly(id: string) { + this.anomalyId = id; + anomalyStorage.saveAnomaly(id); + } + + isResearchEnabled(id: string): boolean { + return id in this.prestigeLevels; + } + + /** Toggling off deletes the key, exactly as locking a building does. */ + toggleResearch(id: string) { + if (id in this.prestigeLevels) delete this.prestigeLevels[id]; + else this.prestigeLevels[id] = 0; + prestigeStorage.savePrestige(this.prestigeLevels); + } + + setResearchLevel(id: string, level: number) { + this.prestigeLevels[id] = level; + prestigeStorage.savePrestige(this.prestigeLevels); + } + + /** The level index, or 0 for an upgrade that is not researched at all. */ + researchLevel(id: string): number { + return this.prestigeLevels[id] ?? 0; + } + toggleBuilding(id: string) { if (id in this.buildingUpgrades) { delete this.buildingUpgrades[id]; diff --git a/src/lib/state/index.ts b/src/lib/state/index.ts index 2616b39..254b149 100755 --- a/src/lib/state/index.ts +++ b/src/lib/state/index.ts @@ -31,7 +31,8 @@ import { solverState } from "./solver.svelte"; * * It lives here rather than on either singleton because it spans both: the * placements being restored need `configState`'s unlock levels to resolve their - * base values, and `layoutState` deliberately does not import `configState`. + * base values and its Time Lab research to be rated under, and `layoutState` + * deliberately does not import `configState`. * * The last solve is re-hung afterwards, in that order because deciding whether * it is still valid means reading the board it was solved against. @@ -45,6 +46,9 @@ import { solverState } from "./solver.svelte"; * the same place a mistyped link should land them. */ export async function hydrateState(): Promise { + // Before the board exists, so the first score every placement gets is already + // under the player's research rather than a frame of unresearched figures. + layoutState.setPrestige(configState.prestige); await layoutState.hydrate(configState.buildingUpgrades); const shared = readSharedCode(); diff --git a/src/lib/state/layout.svelte.ts b/src/lib/state/layout.svelte.ts index 7d6dde4..4a6c47b 100755 --- a/src/lib/state/layout.svelte.ts +++ b/src/lib/state/layout.svelte.ts @@ -17,7 +17,10 @@ import { decodeBlueprint, encodeBlueprint, placementTiers, + prestigeScales, + type BlueprintRules, type DecodedBlueprint, + type PrestigeScales, } from "@reactor2/solver"; import { createPlacement, @@ -575,10 +578,24 @@ class LayoutState { * Saving to `localStorage` deliberately goes the other way and records no * tiers at all — see `#writeBlueprint`. */ + /** + * The share code for a board: its shape, its tiers, **and the rules it was + * built under**. + * + * The rules are passed in rather than read, for the same reason the unlock + * levels are — this class reaches nothing else in the state layer, and both + * the anomaly and the research live on `configState`. + */ exportBlueprint( + rules: BlueprintRules, placements: readonly PlacedBuilding[] = this.placements, ): Promise { - return encodeBlueprint(this.grid, placements, placementTiers(placements)); + return encodeBlueprint( + this.grid, + placements, + placementTiers(placements), + rules, + ); } // ── Preview: someone else's board, opened from a link ──────────────────── @@ -594,6 +611,13 @@ class LayoutState { * fall back to the visitor's levels — the best guess available, and what * every reader did before there was anything better. * + * The same argument runs one level up: a tier is only worth what the author's + * Time Lab research made it worth, so a code that carries rules rates the + * board with **the author's** research, and one that carries none rates it + * with no research at all. That is the honest reading of silence — an old + * code cannot tell us, and reaching for the reader's own would restate + * someone else's board in figures they never saw. + * * Nothing here is persisted and `activeTemplateId` is left alone, so the * island the visitor was last on is still selected underneath and * `exitPreview()` is a plain re-hydrate. @@ -619,6 +643,7 @@ class LayoutState { this.grid = decoded.grid; this.width = decoded.width; this.height = decoded.height; + this.#previewRules = decoded.rules; this.placements = decoded.placements.map((p) => { const tier = decoded.tiers[p.buildingId]; return tier === undefined @@ -1045,8 +1070,62 @@ class LayoutState { this.grid, BUILDINGS, this.placements, + // A previewed board is the author's, rated at the author's research — + // the same reason `rebasePlacements` sits preview out. The code now says + // what that research was, so it is used; a code that does not say rates + // the board unresearched, which is the honest reading of silence and + // never the reader's own. + this.isPreview ? this.#previewPrestige : this.#prestige, ); } + + /** + * The Time Lab research every placement is rated under. + * + * Pushed in rather than read, because this class imports nothing from the + * rest of the state layer and `configState` is where research lives — the + * same arrangement `rebasePlacements` uses for unlock levels, except that + * `recalculate()` is called from a dozen internal places that cannot all take + * an argument, so it is held instead of passed. + */ + #prestige: PrestigeScales | undefined; + + /** + * The rules a previewed board names, and the scales they resolve to. Null off + * a code that carries none — see `loadPreview`. + */ + #previewRules = $state(null); + + #previewPrestige = $derived( + this.#previewRules + ? prestigeScales(this.#previewRules.research) + : undefined, + ); + + /** + * The research every placement on the board **currently on screen** is rated + * under — the author's on a previewed board, the player's otherwise. + * + * Exposed because a reader of those placements has to resolve them the same + * way `recalculate()` did. Reaching for `configState.prestige` instead puts a + * previewed building's used figures on the author's research and its ceilings + * on the reader's, which prints rows like "259AC / 51.8AC" and can paint a + * perfectly cooled building red. + */ + get placementPrestige(): PrestigeScales | undefined { + return this.isPreview ? this.#previewPrestige : this.#prestige; + } + + /** The anomaly a previewed board was built under, or null if unstated. */ + get previewAnomalyId(): string | null { + return this.#previewRules?.anomalyId ?? null; + } + + setPrestige(scales: PrestigeScales) { + if (this.#prestige === scales) return; + this.#prestige = scales; + this.recalculate(); + } } export const layoutState = new LayoutState(); diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index 7252162..fedac80 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -294,6 +294,7 @@ class SolverState { const effectiveBuildings = getEffectiveBuildings( BUILDINGS, configState.buildingUpgrades, + configState.prestige, ); if (!effectiveBuildings.length) return 0; @@ -303,7 +304,10 @@ class SolverState { ); if (!islands.length) return 0; - const tileCounts = islands.map((island) => countGrassTiles(island.grid)); + // `tileCount`, not the window's grass: an island's sub-grid is padded and + // 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(), @@ -329,7 +333,11 @@ class SolverState { if (!grid || !grid.length || !grid[0] || !grid[0].length) return 0; const upgrades = configState.buildingUpgrades; - const effectiveBuildings = getEffectiveBuildings(BUILDINGS, upgrades); + const effectiveBuildings = getEffectiveBuildings( + BUILDINGS, + upgrades, + configState.prestige, + ); if (!effectiveBuildings.length) return 0; const hasCoolingSupport = canCoolDirectProducer(effectiveBuildings); @@ -382,7 +390,15 @@ class SolverState { .sort(([a], [b]) => a.localeCompare(b)) .map(([id, level]) => `${id}:${level}`) .join(","); - return `${blueprintKey(layoutState.grid)}|${roster}`; + // The anomaly and the Time Lab research are both part of the rules the + // layout was found under, so a solve from another set describes a board + // that no longer exists — stale in exactly the way a cleared obstacle or a + // bought upgrade is. + const research = Object.entries(configState.prestigeLevels) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, level]) => `${id}:${level}`) + .join(","); + return `${blueprintKey(layoutState.grid)}|${roster}|${configState.anomalyId}|${research}`; } /** @@ -446,7 +462,12 @@ class SolverState { bound: number, ): OptimizationResult { const grid = layoutState.grid; - const scored = simulatePlacedBuildings(grid, BUILDINGS, placements); + const scored = simulatePlacedBuildings( + grid, + BUILDINGS, + placements, + configState.prestige, + ); let totalPower = 0; let totalHeatProduced = 0; @@ -531,12 +552,14 @@ class SolverState { if (this.variants.length === 0) return; const upgrades = configState.buildingUpgrades; - // `null` from each variant that nothing moved on; if that is all of them, - // the roster change did not touch this board and there is nothing to do. + // `null` from each variant that no *tier* moved on. That is not a reason to + // stop: this also runs for an anomaly or a Time Lab change, neither of which + // moves a tier — they change what the same tier is worth. Returning early + // there left the panel printing figures from the old rules and, worse, left + // the stored record under the old signature, so a reload dropped the solve. const rebased = this.variants.map((variant) => rebaseToUnlocks(variant.placements, upgrades), ); - if (rebased.every((placements) => placements === null)) return; // Every variant is re-rated, not just the one on screen: they are all this // island's answer and the user can cycle to any of them. A tier bought @@ -737,7 +760,15 @@ class SolverState { return; this.optimizationResult = progressResult; }, - { attempts: mode.attempts, attemptBudgetMs: mode.attemptBudgetMs }, + { + attempts: mode.attempts, + attemptBudgetMs: mode.attemptBudgetMs, + // The rules this timeline runs under. The research is folded into + // the roster the coordinator resolves and lands; the anomaly is + // carried the whole way and not yet acted on. + anomalyId: configState.activeAnomaly.id, + prestige: configState.prestige, + }, ); this.activeTask = handle; this.#startWatchdog(mode); diff --git a/src/lib/state/ui.svelte.ts b/src/lib/state/ui.svelte.ts index 3f48465..1957064 100755 --- a/src/lib/state/ui.svelte.ts +++ b/src/lib/state/ui.svelte.ts @@ -3,7 +3,7 @@ import type { ImageScale, PlacedBuilding, PlacementView } from "../types"; import type { BuildingCategory } from "@reactor2/solver"; import { buildShareUrl, clearSharedCode } from "../encoding/shareLink"; import { downloadBlob, toFileSlug } from "../utils/downloadFile"; -import { formatNumberForFilename } from "@reactor2/solver"; +import { blueprintRules, formatNumberForFilename } from "@reactor2/solver"; import { analyticsOptedOut, doNotTrackRequested, @@ -78,7 +78,7 @@ export type SheetDetent = "closed" | "peek" | "half" | "full"; * Defaults to the islands, because that is the recurring task. The roster is * set once and then the same roster is tried against one island after another. */ -type SetupTab = "islands" | "buildings"; +type SetupTab = "islands" | "buildings" | "timelab"; /** * Visible height of the sheet's `peek` detent, in px. @@ -701,7 +701,12 @@ class UIState { this.shareUrl = ""; this.copiedForm = null; try { - const code = await layoutState.exportBlueprint(this.visiblePlacements); + // The rules the board on screen was built under travel with it — this + // is the one place that can see both the board and `configState`. + const code = await layoutState.exportBlueprint( + blueprintRules(configState.anomalyId, configState.prestigeLevels), + this.visiblePlacements, + ); this.shareCode = code; this.shareUrl = buildShareUrl(code); } catch (err) { diff --git a/src/lib/storage/storage.ts b/src/lib/storage/storage.ts index ef47c59..37224a8 100755 --- a/src/lib/storage/storage.ts +++ b/src/lib/storage/storage.ts @@ -1,7 +1,10 @@ import type { CustomIsland } from "../types/building"; import type { ImageScale, PlacementView } from "../types/ui"; import { DEFAULT_SOLVE_MODE, type SolveModeId } from "../worker/solveModes"; -import type { OptimizationResult } from "@reactor2/solver"; +import { + DEFAULT_ANOMALY_ID, + type OptimizationResult, +} from "@reactor2/solver"; export interface SavedTemplateData { /** Blueprint code — terrain and hand-placed buildings, see `lib/encoding/blueprint.ts`. */ @@ -18,6 +21,8 @@ export interface StoragePayload { const KEYS = { GRID_STATE: "grid_state", BUILDINGS: "buildings", + ANOMALY: "anomaly", + PRESTIGE: "prestige", UI: "ui_prefs", SOLVE: "solver_result", } as const; @@ -186,6 +191,38 @@ export const buildingStorage = { setItem(KEYS.BUILDINGS, data), }; +/** + * The anomaly the player's current timeline is running under. + * + * Its own key rather than a field on `ui_prefs`, because it is not a + * preference about the app: it changes the rules a solve runs under, the way + * the roster does, and `solveSignature()` counts it for exactly that reason. A + * bare string, so a record written by a later build naming an anomaly this one + * has never heard of reads back and falls through `getAnomaly` to the baseline. + */ +export const anomalyStorage = { + loadAnomaly: (): string => getItem(KEYS.ANOMALY, DEFAULT_ANOMALY_ID), + saveAnomaly: (id: string): void => setItem(KEYS.ANOMALY, id), +}; + +/** + * Time Lab research: upgrade id -> researched level index. + * + * Same shape and same rule as `buildingUpgrades` above — presence of the key is + * what "researched" means, and the value is a 0-based level index. Two records + * rather than one because they are unrelated catalogues, but one convention, + * because they are picked with the same control. + * + * Its own key rather than joining the anomaly's, even though both are timeline + * state: they are written by different controls at different times, and a + * shared record would make every research level rewrite the anomaly. + */ +export const prestigeStorage = { + loadPrestige: (): Record => getItem(KEYS.PRESTIGE, {}), + savePrestige: (data: Record): void => + setItem(KEYS.PRESTIGE, data), +}; + export const uiStorage = { loadPrefs: (): UiPrefs => ({ ...DEFAULT_UI_PREFS, diff --git a/src/lib/worker/islandWorker.ts b/src/lib/worker/islandWorker.ts index 84e057a..48de8f6 100755 --- a/src/lib/worker/islandWorker.ts +++ b/src/lib/worker/islandWorker.ts @@ -1,4 +1,4 @@ -import { solveIsland } from "@reactor2/solver"; +import { getAnomaly, solveIsland } from "@reactor2/solver"; import type { IslandWorkerRequest, IslandWorkerResponse, @@ -43,6 +43,7 @@ self.addEventListener( }, }, req.rngSeed, + getAnomaly(req.anomalyId), ); post({ id: req.id, diff --git a/src/lib/worker/solverCoordinator.ts b/src/lib/worker/solverCoordinator.ts index 37a62f2..86fbb43 100755 --- a/src/lib/worker/solverCoordinator.ts +++ b/src/lib/worker/solverCoordinator.ts @@ -101,6 +101,8 @@ export class SolverCoordinator { unlockedUpgrades, timeBudgetS, rngSeed, + options?.anomalyId, + options?.prestige, ); if (!plan) return [createEmptyResult(grid?.[0]?.length ? countGrassTiles(grid) : 0)]; @@ -279,6 +281,10 @@ export class SolverCoordinator { effectiveBuildings: plan.effectiveBuildings, timeBudgetS: plan.budgetsS[index], reportIntervalMs: options?.reportIntervalMs, + // Resolved once by `planSolve` and sent as an id: the worker resolves + // it again on the far side, which keeps the message a plain string + // rather than a table entry that has to survive structured cloning. + anomalyId: plan.anomaly.id, // Every attempt needs its own random stream — running the same walk // ten times over would only cost ten times as much. An unseeded run // gets a fresh stream per task already; a seeded one is offset so it diff --git a/src/lib/worker/variants.test.ts b/src/lib/worker/variants.test.ts index 91d4d21..500c89d 100755 --- a/src/lib/worker/variants.test.ts +++ b/src/lib/worker/variants.test.ts @@ -8,6 +8,7 @@ */ import { describe, expect, it } from "vitest"; import { buildSolveVariants } from "./variants"; +import { getAnomaly } from "@reactor2/solver"; import type { IslandPlan } from "@reactor2/solver"; import type { IslandSolution } from "@reactor2/solver"; import type { IslandLayout, PlacedBuilding, Tile } from "@reactor2/solver"; @@ -49,11 +50,14 @@ function plan(): IslandPlan { width: 2, height: 1, grid: [] as Tile[][], + buildable: Uint8Array.from([1, 1]), + tileCount: 2, originalTileIndices: [offset, offset + 1], }); return { islands: [island(0), island(2)], effectiveBuildings: [], + anomaly: getAnomaly(undefined), budgetsS: [1, 1], seeds: [undefined, undefined], originalWidth: ORIGINAL_WIDTH, diff --git a/src/lib/worker/workerClient.ts b/src/lib/worker/workerClient.ts index 7a4f67f..193898e 100755 --- a/src/lib/worker/workerClient.ts +++ b/src/lib/worker/workerClient.ts @@ -1,7 +1,9 @@ import type { Tile, + AnomalyId, BuildingDefinition, OptimizationResult, + PrestigeScales, } from "@reactor2/solver"; import { SolverCoordinator } from "./solverCoordinator"; @@ -42,6 +44,25 @@ export interface SolveRunOptions { attempts?: number; /** What one attempt divides across the islands, ms. */ attemptBudgetMs?: number; + /** + * The timeline's anomaly, by id. Omitted means the base rules. + * + * Carried the whole way to `solveIsland` and **not yet acted on** — the + * rules are modelled and threaded, not implemented. Time Lab research needs + * nothing here: it resolves into `unlockedUpgrades`' effective roster before + * a run starts. + */ + anomalyId?: AnomalyId; + /** + * Time Lab research as resolved scales. **Required for research to reach a + * run at all** — the coordinator resolves the roster itself from `buildings` + * and `unlockedUpgrades`, so without this it builds an unresearched one and + * the search optimises a board the player does not have. + * + * It goes no further than `planSolve`: the roster that crosses to the workers + * already carries it. + */ + prestige?: PrestigeScales; } export interface SolveTaskHandle { @@ -105,6 +126,8 @@ export class SolverWorkerClient { const promise = coordinator .solve(grid, buildings, unlockedUpgrades, timeBudgetS, { attempts: run?.attempts, + anomalyId: run?.anomalyId, + prestige: run?.prestige, reportIntervalMs: this.options.reportIntervalMs, onProgress: (result) => { if (!entry.stopped) entry.onProgress?.(result); From 5bfcc4a140aa86461e8f1abc48f7a89364fbf120 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:11:00 +0300 Subject: [PATCH 02/21] Rewrite the prestige rules from the game's source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anomaly and Time Lab semantics had been modelled from the in-game description text, which was enough for the numbers and not for the rules. The three anomaly classes, the research classes, the getters that apply them, CoolingNetwork.cs and GeneratorBuilding.TickGeneratingEnergy have since been read directly. Most of what was written down survives; four things do not. **There is no full-cooling admission test.** A power source produces whenever it is not overheated or booting, proportionally to the heat it received, and cooling is not consulted. Uncooled waste accumulates into a store and latches the building overheated at 99.5% of its capacity, before that tick's cooling. So an undercooled layout does not go dark — it saws between running and latched. "Cooling is all-or-nothing" stays the rule this project optimises against, but as the criterion for a *sustainable* layout rather than as a thing the game does, and the section now says which it is. One consequence worth having: the overheat threshold can never bind, so scaling it changes nothing here. **Off the board counts as water.** The game has one global map with open water between islands and these eight boards are rectangles cut out of it, so the water past an edge is real and simply absent from the blueprint. It moves the shipped maps barely (+4 tiles on island3, +2 on island7) and is the entirety of Tidal Ascendancy on a custom island, whose blank 10x10 of grass contains no water at all. The trap for the solver is that IslandSubGrid's padding is clamped to the board and cannot tell an off-board neighbour from the window's own edge. **Waste is derived, not scaled.** The game recomputes snapToAuthoredPrecision( heat - energy) from the fully scaled pair, so research and anomaly have to meet at a single multiply with one snap after it. scaleEffectiveBuilding multiplies waste directly, which is right for one factor and wrong for two. **Cryo Nexus drops the minimum island size to one tile.** The 2-and-3-tile floors exist only because cooling must cross a tile boundary. A lone tile takes a heat sink paying into the pool, or a direct producer the pool pays for — 21 tiles across the shipped maps that no other rule in the game can use. Settled without change: Tidal covers all four roles and does not stack; Singularity reads placement rather than state, scales only generators, and neither takes nor gives the penalty to a wind turbine; turbines draw from the Cryo pool; research and anomaly compose multiplicatively; Stellar Forge is heat producers only, which is reactors. Time Lab research also gets a section of its own, having had none. --- CLAUDE.md | 57 ++++++--- docs/SOLVER.md | 46 ++++++-- docs/game-logic.md | 280 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 307 insertions(+), 76 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31fd321..f1a0f14 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,12 +183,13 @@ before the board exists; `App.svelte`'s effect pushes changes. A previewed board sits it out, the same as `rebasePlacements`: it is the author's board at the author's research, and a blueprint does not record what that was. -**Stellar Forge is the one uncertain call.** The game's `heat_producer` category -holds reactors *and* direct producers, so "all Heat Producers" read literally -would take the wind turbine — but Infinite Grid goes out of its way to name wind -turbines, which is only worth saying if this one does not cover them. Modelled as -reactors only; the cost of being wrong is a turbine that per `docs/game-logic.md` -almost never earns a tile. +**Stellar Forge does not cover wind turbines.** The game's `heat_producer` +category holds reactors *and* direct producers, so "all Heat Producers" read +literally would take the turbine — but a turbine's SO inherits +`PowerSourceBuildingSO` and reads Infinite Grid, while Stellar Forge is read only +by `HeatProducerBuildingSO`. That is why Infinite Grid goes out of its way to +name turbines. The rule is "every heat producer", and a reactor is the only one +the game ships, so it is modelled as reactors. ### Anomalies @@ -206,7 +207,12 @@ timeline. `docs/game-logic.md` is the authority on what each one does and and the worker boundary. - Every stat anomaly is a **uniform** scale on a building's three figures (`scaleEffectiveBuilding`), so a bonus is never free power — the cooling it - needs grows with it. + needs grows with it. **Waste is derived rather than scaled**: the game + recomputes `snapToAuthoredPrecision(heat - energy)` from the fully scaled pair, + so research and anomaly have to meet at one multiply with a single snap after + it. `scaleEffectiveBuilding` multiplies `waste` directly instead — which holds + for one factor and breaks for two, in the last digit, where the fixtures assert + exactly. Fixing that is what an anomaly reaching the roster depends on. - `configState.anomalyId` is the live choice, persisted under its own key rather than in `ui_prefs` because it is a **solve input** like the roster, not a preference about the app — which is also why `solveSignature()` counts it and @@ -218,6 +224,24 @@ timeline. `docs/game-logic.md` is the authority on what each one does and solvable alone; it is just no longer described by its best power but by a frontier of power against net cooling contributed, with the board combining those under one scalar budget. See `docs/SOLVER.md`. + + It also **drops `splitGridIntoIslands`' minimum island size to one tile**. The + 2-and-3-tile floors exist only because cooling has to cross a tile boundary; a + lone tile under Cryo takes a heat sink that pays into the pool, or a direct + producer the pool pays for. That is 21 tiles across the shipped maps which no + other rule in the game can use, arriving as degenerate one-tile islands that + must not be handed a real share of the time budget. The anomaly therefore has + to reach the split, not be consulted after it. +- **Off the board counts as water**, which is a fact about our data rather than + about the game: the game has one global map with open water between islands, + and our eight boards are rectangles cut out of it, so the water past an edge is + real and simply not in the blueprint. It barely moves the shipped maps (+4 on + island3, +2 on island7, the rest unchanged) and is the whole of the anomaly on + a custom island, whose blank 10x10 of grass has no water in it at all. + + The trap is that `IslandSubGrid`'s one-tile padding is **clamped to the board**, + so it cannot tell an off-board neighbour from the window's own edge. Water + adjacency has to be resolved once on the full grid and carried in. - **`ANOMALIES` is transcribed by hand from the same extractor's output**, which emits an anomaly record alongside the roster and drops the icons into `public/icons/anomaly_.webp`. Unlike `BUILDING_TABLE`, **nothing splices @@ -252,15 +276,16 @@ timeline. `docs/game-logic.md` is the authority on what each one does and **Three Time Lab upgrades scale building stats too**, and they are not anomalies: they are bought with Chronons, survive a Time Jump, and stack with whatever -anomaly is running. The extractor's record covers all ten Time Lab upgrades; the -three a layout can see are **Stellar Forge** (every heat -producer), **Infinite Grid** (every generator), and **Absolute Zero** (every -cooler), each five levels of `BonusPercentage` 1.0 to 4.0. That field is a -fraction rather than a percent -- the same field is 0.05 on Chronon Reactor, -which the game shows as +5% -- so the levels are worth x2 to x5. Like an -anomaly's, the scale is uniform, so the cooling a boosted producer needs grows -with it. Their badges ship as `public/icons/prestige_.webp`. Nothing models -them yet. +anomaly is running — **multiplicatively**, so a generator under Singularity +Isolation with Infinite Grid maxed is rated x12.5. The extractor's record covers +all ten Time Lab upgrades; the three a layout can see are **Stellar Forge** +(every heat producer, so reactors), **Infinite Grid** (generators *and* wind +turbines), and **Absolute Zero** (every cooler), each five levels of +`BonusPercentage` 1.0 to 4.0. That field is a fraction rather than a percent -- +the same field is 0.05 on Chronon Reactor, which the game shows as +5% -- so the +levels are worth x2 to x5. Like an anomaly's, the scale is uniform, so the +cooling a boosted producer needs grows with it. Their badges ship as +`public/icons/prestige_.webp`. ## Architecture diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 185b431..b4c76c9 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -84,6 +84,12 @@ multiplier belongs to is not in the data at all: a shared cooling pool and a sho both "one double" to it. So the extract is what each entry's numbers and its quoted `description` are checked against, and the `rule` tag stays a human's reading of the text. +What each rule *means*, though, is read off the game's own source — the three anomaly classes, the +getters that apply them, `CoolingNetwork.cs` and `GeneratorBuilding.TickGeneratingEnergy` — and +`docs/game-logic.md` is where that lands. Two of those rules are not what the description text +reads like and are worth naming here: off-board counts as water, and nothing in the runtime +performs a full-cooling admission test. + Two things about the shapes are load-bearing: - **Every stat anomaly is a _uniform_ scale.** "Energy, Heat, Cooling, and overheat capacity" is @@ -91,6 +97,13 @@ Two things about the shapes are load-bearing: is worth more — `scaleEffectiveBuilding` is the one place that applies it, and one function covers every such anomaly there will be. It is not free power: a scaled producer needs proportionally more cooling and goes offline just as readily. + + **Waste is derived, not scaled, and every factor has to be in hand before it is.** The game + recomputes `snapToAuthoredPrecision(heat − energy)` from the *fully scaled* heat and energy, so + the snap must happen once, after research and anomaly have both been applied — scaling the + authored waste instead disagrees in the last digit, and the fixtures assert exactly. That is + why an anomaly cannot simply be a second `scaleEffectiveBuilding` call layered on the prestige + one: the two factors have to arrive at a single multiply. See `docs/game-logic.md`. - **`role_isolation` depends on the layout, the other shapes do not.** A terrain bonus is fixed per tile and can be folded into the board; a generator's isolation multiplier changes every time the search moves a neighbour, so a placed building's figures have to be resolved per @@ -111,13 +124,21 @@ power against net cooling it puts into or takes out of the pool — and the boar combine those frontiers under one scalar budget. That keeps the per-island parallelism; it changes what a worker is asked for. -Two smaller effects: the board is all-or-nothing **together** (every power source gets the same -percentage, and a partial percentage runs nothing), and patches below the minimum island size come -alive, since a lone tile's producer can be cooled from anywhere. The second is worth little — the -shipped maps lose 0 to 6 tiles that way, on boards of 49 to 184. - -`docs/game-logic.md` has the rules. Nothing about them is unconfirmed now: a pond is an obstacle -and grants no shore bonus, and the Tidal bonus lands on 36-44% of the grass of every shipped map. +Two smaller effects. The board is sustainable **together** — every power source is served the same +fraction, so either the pool covers the board's whole waste or no part of it holds — which is what +makes one scalar budget the right shape for the board-level problem. And **`splitGridIntoIslands`' +minimum drops to one tile**: `minTilesRequired` is 2 or 3 only because cooling has to cross a tile +boundary, and under Cryo it does not. A lone tile takes a heat sink that pays into the pool, or a +direct producer the pool pays for; a lone generator or reactor is still worthless, heat being +adjacency-bound either way. The shipped maps have 21 such tiles between them, 0 to 6 each — small, +but they are ground no other rule in the game can use, and they arrive as degenerate one-tile +islands that should not be handed a real share of the time budget. + +`docs/game-logic.md` has the rules. Two are worth repeating here because they decide how much of a +board this package has to look at: a pond is an obstacle and grants no shore bonus, while **off the +board counts as water** — the shipped maps are rectangles cut from one global map that is open +water between islands. The Tidal bonus lands on 36-45% of the grass of every shipped map, and on +the whole perimeter of a custom one. ### `island.ts` — the window an island is solved in @@ -133,6 +154,17 @@ and `tileCount` is the count that goes with it. The padding changes tile coordinates uniformly, so it changes no result: the fixtures reproduce byte for byte across it. +**The padding cannot answer the shore question on its own, and this is the trap.** It is clamped +to the board, so for a tile on the board's own edge the off-board neighbour is not in the window — +and an absent cell there is indistinguishable from the window's own boundary. Since off-board +counts as water, a shore flag resolved from inside an `IslandSubGrid` would rate every border tile +inland, which on a custom island is the entire perimeter and the whole of the anomaly's effect. +So water adjacency has to be resolved **once on the full grid**, before decomposition, and carried +into the sub-grid alongside `originalTileIndices` — not recomputed from the window. + +The same goes for `minTilesRequired`: it is a property of the rules in force, not of the grid, so +the anomaly has to reach `splitGridIntoIslands` rather than being consulted after it. + ### `distribution.ts` — a port of Unity's `FlowNetwork.cs` Two shapes in it are load-bearing and look like details: diff --git a/docs/game-logic.md b/docs/game-logic.md index b307a6b..ec35d11 100755 --- a/docs/game-logic.md +++ b/docs/game-logic.md @@ -19,7 +19,9 @@ afterward, which already-heated buildings get to actually turn on. The grid is made of tiles. Grass tiles are buildable — you can put anything on them. Everything else (water, rock, trees, ponds, transformers) is just scenery: -you can't build there, and it has no effect on anything. +you can't build there, and under the base rules it has no effect on anything. +(Tidal Ascendancy is the one exception in the game, and the only rule anywhere +where an unbuildable tile does something — see Anomalies.) Only one building fits per tile. @@ -40,6 +42,9 @@ A cluster needs a minimum amount of room to ever produce any power at all: Anything smaller than that is a dead zone — it will never generate power, no matter what you put there. +Both numbers are consequences of cooling being adjacency-bound, so both stop +holding under Cryo Nexus: see that anomaly below, where the minimum is one tile. + --- ## Adjacency @@ -235,20 +240,60 @@ fixed, meaningful rule of the game, not just an implementation quirk. --- -## Cooling Is All-Or-Nothing +## Cooling Decides What Keeps Running Once heat has been settled and every generator's power is locked in, cooling -gets distributed the same way. Then comes the real test: a generator or direct -producer only actually runs — and only actually produces its power — if the -cooling it received covers _all_ of its waste heat. Getting cooled exactly as -much as its waste amount is fine; that still counts as fully covered. - -If it falls even slightly short, the building shuts down completely and -produces zero power. There's no partial credit, and nothing gets undone or -sent back — the heat it already received during heat delivery is not returned -to any reactor, and it can't be reassigned to a different generator just -because this building couldn't stay cooled. A building with no cooling routed -to it at all can never run, no matter how much heat it received. +gets distributed the same way. What cooling decides is not whether a building +produces **this** tick — it decides whether it can go on producing. + +### Production, the waste store, and overheating + +There is no admission test. A power source produces whenever it is neither +overheated nor booting, and what it produces is proportional to the heat it +actually received: at 60% of its intake capacity it makes 60% of its energy and +60% of its waste. Cooling is not consulted. + +Instead, every power source carries a **store of waste heat** and a capacity for +it — the overheat threshold. Each tick it adds the waste it just made, and +cooling removes what it can. So: + +- Cooled at least as fast as it makes waste, the store stays empty and the + building runs forever. +- Cooled more slowly, the store climbs a little every tick. When it reaches + 99.5% of the threshold the building **latches overheated** and drops to zero + power. It stays there until cooling has drained the store to nothing, then + reboots — a tick of nothing — and starts climbing again. + +Production and the overheat latch both happen **before** that tick's cooling, so +a building can tip over on the same tick it produced. + +Nothing gets undone or sent back either way: the heat a building received during +heat delivery is not returned to any reactor, and it cannot be reassigned to a +different generator just because this one could not stay cooled. + +### Sustainable layouts, and the rule that describes them + +A layout whose cooling does not cover its waste still produces, on a sawtooth, +cycling between running and latched. It is unbuildable in any useful sense: its +output depends on how long it has been running, it is not a number you can put +on a card, and no player wants a board that stalls. + +So the whole of this document, and the solver behind it, is about **sustainable** +layouts: ones where every power source's cooling covers the waste it is actually +making, every tick, so no store ever fills. For those layouts the simpler rule is +exactly equivalent, and it is the one used everywhere below: + +> A generator or direct producer counts as online, and contributes its power, +> only if the cooling routed to it covers _all_ of its waste heat. Cooled exactly +> as much as its waste is fine — that still counts as covered. Anything short and +> it counts for nothing. + +Two consequences of drawing the line there are worth being explicit about. A +building with no cooling routed to it at all can never be part of a sustainable +layout, whatever heat it received. And because a sustainable layout never +accumulates waste, the overheat threshold is **never the binding constraint** — +which is why no figure for it appears anywhere else in this document, and why an +anomaly or a research that scales it changes nothing here. --- @@ -324,7 +369,8 @@ A few things always hold true no matter how you arrange things: - A reactor can never send out more heat than its own maximum. - A generator can never take in more heat than its own maximum. - Heat is always settled completely before cooling is even looked at. -- A generator or direct producer only contributes power if it's fully cooled. +- A generator or direct producer only contributes power if it's fully cooled — + that being the sustainability rule above, not an admission test the game runs. And ultimately, what matters isn't how many buildings you've placed, or how much heat or cooling moved around the grid — it's the total amount of power actually @@ -332,11 +378,56 @@ being produced by whatever's still online at the end. --- +## Prestige Changes the Numbers Twice + +Prestiging ("Time Jump") ends a timeline and starts a new one. It touches this +document in two independent ways: the player spends Chronons on **Time Lab +research** that survives the jump, and picks one **anomaly** that governs the +whole of the next timeline. They stack, and neither is a special case of the +other. + +**Everything in these two sections is read off the game's own source** — the +three anomaly classes, the Time Lab research classes, the building getters that +apply them, and the cooling network. The in-game description text is not the +authority: several of its lines have more than one consistent reading, and where +it and the code differ, what is written here is the code. + +--- + +## Time Lab Research + +Ten researches exist; **three** of them change a building's figures, and the +other seven move research income, research time, Chronons, obstacle-removal +cost and building prices — all decided before a board is laid out, so none can +change which layout is best. + +| research | scales | +| ------------------ | --------------------------------------------- | +| **Stellar Forge** | every heat producer's heat output | +| **Infinite Grid** | every generator's and wind turbine's heat intake, energy and overheat capacity | +| **Absolute Zero** | every cooler's cooling | + +Each has five levels worth ×2, ×2.5, ×3.25, ×4 and ×5. The game authors them as +a bonus fraction — 1.0 through 4.0 — and shows them as "+100%" through "+400%"; +the multiplier is `1 + bonus`. + +**The roles do not overlap, and the boundary is the class of building rather +than the catalogue's grouping.** The game's catalogue files reactors and wind +turbines together under one "heat producer" category, but a wind turbine is a +*power source* in the code, so Stellar Forge does not touch it — Infinite Grid +does, which is why that one goes out of its way to name wind turbines. A reactor +is the only heat producer the shipped roster has, so in practice Stellar Forge +is "reactors"; it would cover any other heat producer the game added. + +Like an anomaly's, each scale is **uniform** on the figures it names, so a +boosted producer needs proportionally more cooling and none of it is free power. + +--- + ## Anomalies -Prestiging ("Time Jump") ends a timeline and starts a new one, and on the way -out the player picks an **anomaly**: a rule change that applies for the whole of -the next timeline. Exactly one is active at a time, and "no anomaly" is a real +On the way out of a Time Jump the player picks an **anomaly**: a rule change +that applies for the whole of the next timeline. Exactly one is active at a time, and "no anomaly" is a real option rather than a missing value — it means the rules exactly as described above. @@ -344,29 +435,59 @@ An anomaly never adds a building, removes one, or changes the grid. It changes either **what a placed building's figures are** or **how a supply gets shared out**, and the three that exist so far divide cleanly along that line. -### The figures an anomaly scales +### What a multiplier actually multiplies + +This applies to Time Lab research just as much as to an anomaly — the two enter +at different points in the game's code, but they scale the same fields in the +same way, and everything in this subsection covers both. -Every stat anomaly so far scales a building **uniformly**: the same multiplier -lands on all of its authored figures at once. The game's wording lists them -separately — "Energy, Heat, Cooling, and overheat capacity" — but those four -names are one field each across the four roles: +Every stat multiplier so far is **uniform**: it lands on all of a building's +authored figures at once. The game's wording lists them separately — "Energy, +Heat, Cooling, and overheat capacity" — but those are one field each across the +four roles: -| the game says | the figure | -| ------------------ | --------------------------------------------------- | -| Heat | a reactor's output, a generator's intake capacity, a direct producer's own heat | -| Energy | what a generator or direct producer puts out at full | -| Cooling | what a cooler absorbs | -| overheat capacity | the waste heat a generator or direct producer makes at full, and therefore what must be cooled for it to stay online | +| the game says | the figure | +| ----------------- | ------------------------------------------------------------------------------- | +| Heat | a reactor's output, a generator's intake capacity, a direct producer's own heat | +| Energy | what a generator or direct producer puts out at full | +| Cooling | what a cooler absorbs | +| overheat capacity | the size of a power source's waste-heat store — not the waste it makes | So a building with a ×1.67 bonus is simply a building whose whole tier is worth 1.67× as much: it absorbs more, produces more, and needs proportionally more -cooling. A bonus is **not** free power on its own — it moves the whole balance -of a layout, and a scaled producer that outruns its coolers goes offline like -any other. - -The multiplier is applied to the authored figure at solve time and is not -snapped back to the game's authored precision, because it is a runtime -multiply rather than a table entry. +cooling. A bonus is **not** free power on its own — it moves the whole balance of +a layout, and a scaled producer that outruns its coolers stops running like any +other. + +**Waste heat is not in that list, and that is the one thing here easy to get +wrong.** A power source's waste is never an independent figure: it is always the +difference between its heat and its energy, recomputed from whatever those two +currently are. So the order of operations for a generator or direct producer is: + +``` +heat = authoredHeat × timeLab × anomaly +energy = authoredEnergy × timeLab × anomaly +capacity = authoredCapacity × timeLab × anomaly +waste = snapToAuthoredPrecision(heat − energy) ← once, at the end +``` + +Scaling the authored waste directly instead — `authoredWaste × timeLab × +anomaly` — is wrong. It agrees to about fifteen digits and disagrees in the +last, because the snap is a decimal rounding and it is applied to the *scaled* +difference, not carried along from the table. + +Nothing else is snapped. Heat, energy, capacity and a cooler's cooling are left +as raw products, because a multiplier is a runtime multiply rather than an +authored table entry; the derived waste is the single exception, and it is +snapped exactly once no matter how many multipliers went into it. + +**Anomaly and research compose multiplicatively.** A generator under Singularity +Isolation (×2.5) with Infinite Grid maxed (×5) is rated ×12.5. That is a +*rating*, not a promise of 12.5× the power: what it actually produces still +depends on how much heat reaches it. + +And **overheat capacity scaling changes nothing for a sustainable layout**, +since the store it sizes never fills. It is listed because the game lists it. ### Cryo Nexus — one shared cooling pool @@ -382,6 +503,15 @@ one number for the island and one rule for handing it out. Heat delivery is untouched: reactors still only reach adjacent generators, and heat is still settled first. +The arithmetic is exactly as plain as it sounds. Each tick the pool adds up +every cooler's cooling; every power source asks for the waste it currently has +stored; and if the pool falls short, everyone is served the same fraction of +what they asked for. **Wind turbines draw from it too** — they are power sources +like any other, even though they take no part in heat delivery. + +The ×0.88 applies to each cooler's own cooling rating, so it is what the game +shows on that cooler, not a separate charge levied at the pool. + Two consequences fall straight out of it, and both matter more than the rule itself: @@ -389,14 +519,18 @@ itself: the island is worth exactly as much as one wedged between two generators, so the layout problem collapses to "how much total cooling do I buy, and where do I spend the tiles I have left". -- **Cooling becomes all-or-nothing for the whole island at once.** Every power - source gets the same percentage of what it needs, and a building only runs if - it is covered in full — so either the pool covers the island's entire waste - and everything runs, or it falls short and *every* power source on that island - shuts down. There is no partial board. +- **Sustainability becomes a single board-wide sum.** Every power source is + served the same fraction, so either the pool covers the board's entire waste — + the fraction is 1, every store stays empty, everything keeps running — or it + does not, and every store on the board fills together. There is no layout under + Cryo where one patch is sustainable and another is not. + + What that shortfall looks like is the sawtooth of the cooling section above, + board-wide and in step, not an instant blackout. It is still the line between a + layout that works and one that does not. -The ×0.88 is a straight tax on each cooler's contribution, applied before -anything is shared. + The sum is over **actual** waste, not nameplate waste: a generator at 60% of + its intake makes 60% of its waste, and it is that figure the pool has to cover. **"An island" here means the whole map.** Gale Hills is an island; so is Ash Bay. The rule is board-wide: every cooler anywhere on the map pays into one @@ -426,17 +560,27 @@ the pool over what it takes out. It can run a producer it cannot cool and let the rest of the board pay for it, or pave itself in coolers and produce nothing but surplus. -And because cooling is all-or-nothing, and every power source gets the same -percentage, **the whole board is all-or-nothing together**: either the pool -covers the board's entire waste and everything runs, or it falls short and every -power source on the map produces nothing. There is no partial board and no -partially-lit patch. +And because every power source is served the same fraction, **the board stands or +falls together**: the pool either covers the board's entire waste or it does not, +and there is no arrangement where one patch is fine and another is cycling. + +**The minimum patch size drops to one tile**, and this is the consequence most +worth acting on. Normally a patch needs 3 tiles (reactor, generator, cooler) or +2 (direct producer, cooler); both numbers exist only because cooling has to +reach across a tile boundary, and here it does not. Under Cryo a single isolated +grass tile is worth building on two different ways: -One smaller consequence: a patch too small to work normally becomes usable, since -it no longer needs a cooler of its own. A lone grass tile with a direct producer -on it runs off the pool. That is worth having but it is not much — the shipped -maps lose between 0 and 6 tiles to patches below the minimum size, out of boards -of 49 to 184 grass tiles. +- a **heat sink** on it pays into the pool for the whole board, which is the + better use of a tile that can never have a neighbour; +- a **direct producer** on it makes power and lets the rest of the board pay for + its waste. + +A lone generator or reactor is still worthless, because heat is still +adjacency-bound and a tile with no neighbours can neither receive nor send it. + +The shipped maps have 21 such tiles between them — 0 to 6 each, on boards of 49 +to 184 grass tiles. Small, but they are free: under every other rule in the game +they are unbuildable ground. ### Tidal Ascendancy — a bonus for building on the shore @@ -451,10 +595,27 @@ per island and then treat as part of the board. It is the first rule in the game where a non-buildable tile does anything at all. +It covers **every** role, not only the ones "production building" sounds like: a +cooler beside water absorbs ×1.67, a reactor beside water puts out ×1.67, and a +generator or wind turbine beside water has all three of its figures scaled. And +one water neighbour is worth the same as eight — the bonus does not stack. + **A pond is not water.** It is an obstacle — scenery you cannot build on, like a rock or a tree — and it grants no shore bonus. Only the water tile proper does. -The bonus is not a rare one: between 36% and 44% of the grass on every shipped +**Off the edge of the board counts as water.** The game has one global map on +which every island sits in open water, and the eight boards here are rectangles +cut out of it — so the water beyond an edge is real water that our rectangle +simply does not include. A grass tile on the border is therefore shore. + +It decides far more on a custom island than on a shipped one. On six of the eight +shipped maps the edge grass already touches water diagonally inside the +rectangle, so the rule adds nothing; on Magma Rift it adds 4 tiles and on Entropy +Isles 2. A blank custom island is 10×10 of nothing but grass, with no water +inside it at all, so the rule is the whole of the anomaly there: its 36-tile +perimeter is shore and its 64-tile interior is not. + +The bonus is not a rare one: between 36% and 45% of the grass on every shipped map is water-adjacent, so on any of them something close to half the buildable board is worth ×1.67. That makes the shoreline the most valuable ground on the map and this anomaly a genuine change of shape rather than a modifier, since the @@ -476,3 +637,16 @@ per layout rather than once per tile. The threshold is binary — one adjacent generator costs the same as five — so the rule is a two-way test, not a count. + +Three things about the test, each of which could have gone the other way: + +- **It reads placement, never state.** An adjacent generator that is idle, + booting or overheated still costs its neighbour the penalty. The multiplier is + a function of the board alone, so it never has to be resolved against an + outcome that it is itself an input to. +- **Only generators are affected, and only generators trigger it.** A wind + turbine is a power source but not a generator: it neither takes the penalty + itself nor imposes it on a generator beside it. Under this anomaly coolers, + reactors and wind turbines are all rated exactly ×1. +- **Adjacency is the ordinary eight neighbours**, corners included, as + everywhere else in this document. From fee40c1f73a71f99ece600eb049d36684fd00267 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:27:16 +0300 Subject: [PATCH 03/21] Derive a scaled building's waste instead of scaling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game holds no waste figure at runtime: its getter is `(HeatPerTick - EnergyPerTick).SnapToAuthoredPrecision()` over whatever heat and energy currently are, so waste is re-derived from the scaled pair every time something scales them. `scaleEffectiveBuilding` multiplied the incoming waste instead, which agrees to about fifteen digits and disagrees in the last — generator7's top tier under Tidal's x1.67 lands on 3.6907e21 derived against 3.6906999999999996e21 scaled. The golden fixtures assert exactly. This is also what makes the function composable, which an anomaly needs. The game applies research in the SO getter and the anomaly in the runtime getter, one after the other, and `(authored x research) x anomaly` is not the same double as `authored x (research x anomaly)`. So the two arrive as two successive calls and each re-derives waste from the pair it produced. Only generators and direct producers derive one. A cooler and a reactor have no energy figure, so `heat - energy` would quietly turn a reactor's whole output into waste needing cooling, and a cooler's whole cooling into waste. No fixture moves: every one of the catalogue's 55 producer tiers already satisfies `waste == snap(heat - energy)`, so at a factor of one the derivation is the identity, and nothing in the fixtures is scaled. Regenerated to confirm — byte for byte. --- CLAUDE.md | 21 ++- .../solver/src/data/effectiveBuildings.ts | 50 ++++-- packages/solver/tests/scaling.test.ts | 153 ++++++++++++++++++ 3 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 packages/solver/tests/scaling.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f1a0f14..346ec80 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,12 +207,21 @@ timeline. `docs/game-logic.md` is the authority on what each one does and and the worker boundary. - Every stat anomaly is a **uniform** scale on a building's three figures (`scaleEffectiveBuilding`), so a bonus is never free power — the cooling it - needs grows with it. **Waste is derived rather than scaled**: the game - recomputes `snapToAuthoredPrecision(heat - energy)` from the fully scaled pair, - so research and anomaly have to meet at one multiply with a single snap after - it. `scaleEffectiveBuilding` multiplies `waste` directly instead — which holds - for one factor and breaks for two, in the last digit, where the fixtures assert - exactly. Fixing that is what an anomaly reaching the roster depends on. + needs grows with it. **Waste is derived rather than scaled**: + `scaleEffectiveBuilding` recomputes `snapToAuthoredPrecision(heat - energy)` + from the pair it just scaled, because that is the game's runtime getter and + carrying the authored waste through the same multiply disagrees in the last + digit, where the fixtures assert exactly. Only the two roles that *have* waste + derive one — `heat - energy` over a cooler or a reactor would turn its whole + output into waste. + + **Research and anomaly arrive as two successive calls, in that order**, which + is how the game applies them (the Time Lab in the SO getter, the anomaly in + the runtime getter) and is not the same double as one combined factor: + generator7's first tier under x5 then x2.5 is 1.3875000000000001e22, against + 1.3875e22 for x12.5. `scaling.test.ts` pins both, against the shipped + catalogue rather than round numbers — every divergence here is in the last bit, + so a test on tidy figures passes under either reading. - `configState.anomalyId` is the live choice, persisted under its own key rather than in `ui_prefs` because it is a **solve input** like the roster, not a preference about the app — which is also why `solveSignature()` counts it and diff --git a/packages/solver/src/data/effectiveBuildings.ts b/packages/solver/src/data/effectiveBuildings.ts index 555d43e..f5ac94c 100755 --- a/packages/solver/src/data/effectiveBuildings.ts +++ b/packages/solver/src/data/effectiveBuildings.ts @@ -194,20 +194,41 @@ export function effectiveAtValue( /** * Re-rates a resolved building by a uniform multiplier. * - * Every anomaly that changes a building's numbers scales **all three of them by - * the same factor** — the game lists "Energy, Heat, Cooling, and overheat - * capacity" separately, but those four names are one field each across the four - * roles, so a bonus is simply a building whose whole tier is worth more. That - * is why one function covers both stat anomalies and will cover the next one. + * Every multiplier that changes a building's numbers — a Time Lab research, a + * stat anomaly — scales **all of its authored figures by the same factor**. The + * game lists "Energy, Heat, Cooling, and overheat capacity" separately, but + * those four names are one field each across the four roles, so a bonus is + * simply a building whose whole tier is worth more. One function covers every + * such rule there will be. * * Which means a multiplier is *not* free power: a scaled producer absorbs more, * makes more, and needs proportionally more cooling, so it goes offline exactly * as readily as an unscaled one. Only `wasteIsCovered` decides that, and it is * relative. * - * The product is left unsnapped, unlike the authored figures it comes from: the - * game applies this at runtime rather than authoring a table entry for it, so - * `snapToAuthoredPrecision` would be inventing a rounding the game does not do. + * **Waste is derived here rather than scaled, and that is the whole point of + * the function.** The game holds no waste figure of its own at runtime: its + * getter is `(HeatPerTick - EnergyPerTick).SnapToAuthoredPrecision()` over + * whatever heat and energy currently are, so waste is re-derived from the + * scaled pair every time something scales them. Multiplying the incoming waste + * instead agrees to about fifteen digits and disagrees in the last, because the + * snap is a decimal rounding of the *scaled* difference rather than something + * carried along from the table — and the golden fixtures assert exactly. + * + * That is also what makes this composable. The game applies research in the SO + * getter and the anomaly in the runtime getter, one after the other, and + * `(authored × research) × anomaly` is not bit-identical to + * `authored × (research × anomaly)`. So the two arrive as two successive calls + * here, in that order, and each re-derives waste from the pair it produced — + * which is exactly what the game does. + * + * Only the two roles that *have* waste derive one. A cooler and a reactor have + * no energy figure, so `heat - energy` would silently turn their whole output + * into waste. + * + * Heat, energy and cooling are left unsnapped: the game multiplies those at + * runtime rather than authoring a table entry, so snapping them would be + * inventing a rounding it does not do. * * Returns the building unchanged at a factor of 1, so the common case allocates * nothing and the identity holds by reference. @@ -217,12 +238,19 @@ export function scaleEffectiveBuilding( factor: number, ): EffectiveBuilding { if (factor === 1) return building; + + const effectiveValue = building.effectiveValue * factor; + const energy = building.energy * factor; + return { id: building.id, type: building.type, - effectiveValue: building.effectiveValue * factor, - energy: building.energy * factor, - waste: building.waste * factor, + effectiveValue, + energy, + waste: + building.type === "generator" || building.type === "direct_producer" + ? snapToAuthoredPrecision(effectiveValue - energy) + : building.waste, // Carried through untouched, and that is the point of it: it identifies the // tier, and a tier does not change because something scaled what it is // worth. See `EffectiveBuilding.baseValue`. diff --git a/packages/solver/tests/scaling.test.ts b/packages/solver/tests/scaling.test.ts new file mode 100644 index 0000000..e2f676f --- /dev/null +++ b/packages/solver/tests/scaling.test.ts @@ -0,0 +1,153 @@ +/** + * `scaleEffectiveBuilding` — the one place a multiplier reaches a building. + * + * Time Lab research and every stat anomaly go through it, so its arithmetic is + * the arithmetic of the whole prestige feature. Nothing in it can fail loudly: + * a slip here produces a board that is simply rated wrong, with no crash and no + * layout that looks odd, and the golden fixtures assert to the last digit. + * + * Three rules, each pinned below against the shipped catalogue rather than + * against round synthetic numbers — the divergences are in the last bit, so a + * test built on tidy figures would pass under either reading. + */ +import { describe, expect, it } from "vitest"; +import { BUILDINGS } from "../src/data/buildings"; +import { scaleEffectiveBuilding } from "../src/data/effectiveBuildings"; +import { snapToAuthoredPrecision } from "../src/solver/physics"; +import type { EffectiveBuilding } from "../src/solver/types"; + +/** The authored tiers of the roster's top generator, the sharpest case here. */ +const G7 = BUILDINGS.find((b) => b.id === "generator7")!; +if (G7.type !== "generator") throw new Error("generator7 is not a generator"); + +/** A resolved building at an authored tier, the way the roster produces one. */ +const atTier = (tier: number): EffectiveBuilding => { + const level = G7.levels[tier]; + return { + id: G7.id, + type: "generator", + effectiveValue: level.heat, + energy: level.energy, + waste: level.waste ?? snapToAuthoredPrecision(level.heat - level.energy), + baseValue: level.heat, + }; +}; + +describe("waste is derived from the scaled pair, not scaled itself", () => { + it("differs from the scaled authored waste, and takes the derived value", () => { + // generator7 at tier 4 under Tidal Ascendancy. The game's runtime getter is + // `(HeatPerTick - EnergyPerTick).SnapToAuthoredPrecision()` over the scaled + // pair, so this is 3.6907e21 — carrying the authored 2.21e21 through the + // same multiply gives 3.6906999999999996e21, half a million short. + const scaled = scaleEffectiveBuilding(atTier(3), 1.67); + + expect(scaled.waste).toBe(3.6907e21); + expect(scaled.waste).not.toBe(2.21e21 * 1.67); + expect(scaled.waste).toBe( + snapToAuthoredPrecision(scaled.effectiveValue - scaled.energy), + ); + }); + + it("holds for a producer the other way up, at single-digit magnitudes", () => { + // The wind turbine's 3.75/3/0.75 goes the opposite way at x0.8: derived is + // exactly 0.6, scaled authored is 0.6000000000000001. Both directions have + // to be wrong for the identity to be an accident. + const turbine = BUILDINGS.find((b) => b.id === "wind_turbine")!; + if (turbine.type !== "direct_producer") throw new Error("not a producer"); + const level = turbine.levels[2]; + + const scaled = scaleEffectiveBuilding( + { + id: turbine.id, + type: "direct_producer", + effectiveValue: level.heat, + energy: level.energy, + waste: level.waste!, + baseValue: level.heat, + }, + 0.8, + ); + + expect(scaled.waste).toBe(0.6); + expect(scaled.waste).not.toBe(0.75 * 0.8); + }); + + it("leaves a cooler's and a reactor's waste at zero", () => { + // The roles with no energy figure. Deriving `heat - energy` for them would + // turn a reactor's entire output into waste it then has to have cooled, + // and a cooler's entire cooling into waste — silently, since both fields + // are plain numbers and nothing downstream range-checks them. + const cooler = scaleEffectiveBuilding( + { + id: "c", + type: "cooler", + effectiveValue: 100, + energy: 0, + waste: 0, + baseValue: 100, + }, + 2.5, + ); + const reactor = scaleEffectiveBuilding( + { + id: "r", + type: "reactor", + effectiveValue: 200, + energy: 0, + waste: 0, + baseValue: 200, + }, + 2.5, + ); + + expect([cooler.effectiveValue, cooler.waste]).toEqual([250, 0]); + expect([reactor.effectiveValue, reactor.waste]).toEqual([500, 0]); + }); +}); + +describe("two multipliers compose as two calls, not as one product", () => { + it("applies research and anomaly successively, as the game's getters do", () => { + // The game multiplies the authored figure by the Time Lab bonus in the SO + // getter and by the anomaly in the runtime getter, so what a building is + // rated at is `(authored x research) x anomaly`. That is not the same + // double as `authored x (research x anomaly)`: Infinite Grid maxed (x5) and + // Singularity Isolation isolated (x2.5) over generator7's first tier give + // 1.3875000000000001e22 one way and 1.3875e22 the other. + const researched = scaleEffectiveBuilding(atTier(0), 5); + const both = scaleEffectiveBuilding(researched, 2.5); + + expect(both.effectiveValue).toBe(1.11e21 * 5 * 2.5); + expect(both.effectiveValue).toBe(1.3875000000000001e22); + expect(both.effectiveValue).not.toBe(1.11e21 * (5 * 2.5)); + }); + + it("re-derives waste at each step, so the last one wins", () => { + // Waste after two calls is the snapped difference of the twice-scaled pair, + // never the once-derived waste carried through a second multiply. + const both = scaleEffectiveBuilding(scaleEffectiveBuilding(atTier(1), 5), 1.67); + + expect(both.waste).toBe( + snapToAuthoredPrecision(both.effectiveValue - both.energy), + ); + }); +}); + +describe("what a multiplier must not touch", () => { + it("carries baseValue through untouched", () => { + // It identifies the tier, and a tier does not change because something + // scaled what it is worth. `effectiveAtValue` matches the catalogue on this + // number, so a scaled one resolves to a higher tier and scales twice. + const scaled = scaleEffectiveBuilding(atTier(2), 1.67); + + expect(scaled.baseValue).toBe(4.43e21); + expect(scaled.effectiveValue).not.toBe(scaled.baseValue); + }); + + it("returns the building itself at a factor of one", () => { + // By reference: the unmodified roster is the common case, and every solve + // resolves one. + const building = atTier(0); + + expect(scaleEffectiveBuilding(building, 1)).toBe(building); + }); +}); From d88b3b78fae8680ffd04cd441ef1b6ce5a32dceb Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:35:01 +0300 Subject: [PATCH 04/21] Resolve water adjacency on the full grid, and the island floor from the rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two board-level facts the search will need, both of which have to be settled before the grid is decomposed rather than after. `computeWaterAdjacency` runs once on the whole board and each `IslandSubGrid` carries a `waterAdjacent` mask indexed like `buildable`. It cannot be resolved from the window: the padding is clamped to the board, so for a tile on the board's own edge the off-board neighbour is not in the window at all, and the natural bounds-check-and-skip reads it as inland. Since off the board counts as water — one global map, open water between islands, these boards cut out of it — that would rate a custom island's entire perimeter wrong, which is the whole of Tidal Ascendancy there. `minIslandTiles` replaces the inline 2-or-3, and returns 1 under a shared cooling pool. Both ordinary floors hold only because cooling has to cross a tile boundary; pooled, a lone tile takes a cooler that pays into the pool or a direct producer the pool pays for. That is 21 tiles across the shipped maps that no other rule in the game can use. `splitGridIntoIslands` therefore takes the anomaly, and every caller passes the one its run is planned under — including the app's two estimates, which would otherwise decompose the board differently from the run they describe. Nothing reads `waterAdjacent` yet, and no fixture moves. --- CLAUDE.md | 22 ++--- docs/SOLVER.md | 35 ++++---- packages/solver/src/solver/island.ts | 88 ++++++++++++++++++- packages/solver/src/solver/solver.ts | 16 ++-- packages/solver/src/solver/types.ts | 16 ++++ packages/solver/tests/island.test.ts | 123 +++++++++++++++++++++++++++ src/lib/state/solver.svelte.ts | 10 ++- src/lib/worker/variants.test.ts | 1 + 8 files changed, 274 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 346ec80..d1bcfb1 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -234,13 +234,14 @@ timeline. `docs/game-logic.md` is the authority on what each one does and frontier of power against net cooling contributed, with the board combining those under one scalar budget. See `docs/SOLVER.md`. - It also **drops `splitGridIntoIslands`' minimum island size to one tile**. The - 2-and-3-tile floors exist only because cooling has to cross a tile boundary; a - lone tile under Cryo takes a heat sink that pays into the pool, or a direct - producer the pool pays for. That is 21 tiles across the shipped maps which no - other rule in the game can use, arriving as degenerate one-tile islands that - must not be handed a real share of the time budget. The anomaly therefore has - to reach the split, not be consulted after it. + It also makes **`minIslandTiles` 1**. The 2-and-3-tile floors hold only + because cooling has to cross a tile boundary; a lone tile under Cryo takes a + heat sink that pays into the pool, or a direct producer the pool pays for. + That is 21 tiles across the shipped maps which no other rule in the game can + use. `splitGridIntoIslands` therefore takes the anomaly — the floor is a + property of the rules, so it cannot be applied after the split — and every + caller passes the same one the run is planned under, the app's two estimates + included, or the bound and the clock describe a different board from the run. - **Off the board counts as water**, which is a fact about our data rather than about the game: the game has one global map with open water between islands, and our eight boards are rectangles cut out of it, so the water past an edge is @@ -248,9 +249,10 @@ timeline. `docs/game-logic.md` is the authority on what each one does and island3, +2 on island7, the rest unchanged) and is the whole of the anomaly on a custom island, whose blank 10x10 of grass has no water in it at all. - The trap is that `IslandSubGrid`'s one-tile padding is **clamped to the board**, - so it cannot tell an off-board neighbour from the window's own edge. Water - adjacency has to be resolved once on the full grid and carried in. + `IslandSubGrid`'s one-tile padding is **clamped to the board**, so it cannot + tell an off-board neighbour from the window's own edge — which is why + `computeWaterAdjacency` runs on the full grid before the split and each + sub-grid carries a `waterAdjacent` mask indexed like `buildable`. - **`ANOMALIES` is transcribed by hand from the same extractor's output**, which emits an anomaly record alongside the roster and drops the icons into `public/icons/anomaly_.webp`. Unlike `BUILDING_TABLE`, **nothing splices diff --git a/docs/SOLVER.md b/docs/SOLVER.md index b4c76c9..3197731 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -126,13 +126,13 @@ what a worker is asked for. Two smaller effects. The board is sustainable **together** — every power source is served the same fraction, so either the pool covers the board's whole waste or no part of it holds — which is what -makes one scalar budget the right shape for the board-level problem. And **`splitGridIntoIslands`' -minimum drops to one tile**: `minTilesRequired` is 2 or 3 only because cooling has to cross a tile -boundary, and under Cryo it does not. A lone tile takes a heat sink that pays into the pool, or a -direct producer the pool pays for; a lone generator or reactor is still worthless, heat being -adjacency-bound either way. The shipped maps have 21 such tiles between them, 0 to 6 each — small, -but they are ground no other rule in the game can use, and they arrive as degenerate one-tile -islands that should not be handed a real share of the time budget. +makes one scalar budget the right shape for the board-level problem. And **`minIslandTiles` returns +1**: the 2-and-3-tile floors hold only because cooling has to cross a tile boundary, and under Cryo +it does not. A lone tile takes a heat sink that pays into the pool, or a direct producer the pool +pays for; a lone generator or reactor stays worthless, heat being adjacency-bound either way, and +the search leaves that tile empty. The shipped maps have 21 such tiles between them, 0 to 6 each — +small, but they are ground no other rule in the game can use. They arrive as degenerate one-tile +islands; the budget split is proportional to tile count, so each draws a share to match. `docs/game-logic.md` has the rules. Two are worth repeating here because they decide how much of a board this package has to look at: a pond is an obstacle and grants no shore bonus, while **off the @@ -154,16 +154,17 @@ and `tileCount` is the count that goes with it. The padding changes tile coordinates uniformly, so it changes no result: the fixtures reproduce byte for byte across it. -**The padding cannot answer the shore question on its own, and this is the trap.** It is clamped -to the board, so for a tile on the board's own edge the off-board neighbour is not in the window — -and an absent cell there is indistinguishable from the window's own boundary. Since off-board -counts as water, a shore flag resolved from inside an `IslandSubGrid` would rate every border tile -inland, which on a custom island is the entire perimeter and the whole of the anomaly's effect. -So water adjacency has to be resolved **once on the full grid**, before decomposition, and carried -into the sub-grid alongside `originalTileIndices` — not recomputed from the window. - -The same goes for `minTilesRequired`: it is a property of the rules in force, not of the grid, so -the anomaly has to reach `splitGridIntoIslands` rather than being consulted after it. +**The padding cannot answer the shore question, which is why `waterAdjacent` rides along.** The +window is clamped to the board, so for a tile on the board's own edge the off-board neighbour is +not in it — and an absent cell there is indistinguishable from the window's own boundary. Since +off-board counts as water, a shore flag resolved from inside an `IslandSubGrid` reads every border +tile as inland, which on a custom island is the entire perimeter and the whole of the anomaly's +effect. So `computeWaterAdjacency` runs **once on the full grid** before decomposition and each +sub-grid carries a copy indexed like `buildable`. `island.test.ts` pins it on a board where the +window is clamped against three edges. + +`splitGridIntoIslands` takes the anomaly for the same class of reason: `minIslandTiles` is a +property of the rules in force rather than of the grid, so it cannot be applied after the split. ### `distribution.ts` — a port of Unity's `FlowNetwork.cs` diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index ba5e40b..c627a7b 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -5,7 +5,12 @@ import { GENERATOR_WASTE_RATIO, } from "./constants"; import { generatorEnergyRatio, generatorWasteRatio } from "./physics"; -import type { EffectiveBuilding, IslandSubGrid, Tile } from "./types"; +import type { + AnomalyDefinition, + EffectiveBuilding, + IslandSubGrid, + Tile, +} from "./types"; /** * Island decomposition and per-island upper bounds. @@ -45,19 +50,88 @@ export function canCoolDirectProducer(roster: EffectiveBuilding[]): boolean { return false; } +/** + * The fewest tiles a patch needs before anything on it can be worth building. + * + * Both ordinary floors exist for one reason: cooling has to cross a tile + * boundary, so a producer needs a cooler beside it. A reactor + generator + + * cooler chain is 3 tiles; a direct producer needs no reactor, so it is 2 when + * one cooler can cover one producer's whole waste. + * + * **Under a shared cooling pool that reason is gone and the floor is 1.** A lone + * tile takes a cooler, which pays into the pool for the whole board, or a direct + * producer, whose waste the board pays for. A lone generator or reactor stays + * worthless — heat is adjacency-bound under every rule — but the search works + * that out for itself and leaves the tile empty, which costs nothing. + */ +export function minIslandTiles( + canCoolDp: boolean, + anomaly?: AnomalyDefinition, +): number { + if (anomaly?.rule === "shared_cooling") return 1; + return canCoolDp ? 2 : 3; +} + +/** + * Whether each tile has water among its eight neighbours, **off the board + * counting as water**. Indexed `y * width + x` over the full grid. + * + * Resolved here, on the whole board, because it cannot be resolved anywhere + * downstream: an `IslandSubGrid`'s padding is clamped to the board, so a tile on + * the board's own edge has no off-board neighbour inside its window to test. + * + * Off the board is water because the game has one global map on which every + * island sits in open water, and these boards are rectangles cut out of it — so + * the water past an edge is real water this rectangle does not include. A pond + * is not water; it is an obstacle, like a rock. See `docs/game-logic.md`. + */ +export function computeWaterAdjacency(grid: Tile[][]): Uint8Array { + const height = grid.length; + const width = grid[0]?.length ?? 0; + const flags = new Uint8Array(width * height); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let shore = false; + for (const [dx, dy] of CHEBYSHEV_DIRECTIONS) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || nx >= width || ny < 0 || ny >= height) { + shore = true; + break; + } + if (grid[ny][nx]?.type === "water") { + shore = true; + break; + } + } + if (shore) flags[y * width + x] = 1; + } + } + + return flags; +} + /** * Splits the grid into independently solvable sub-grids of buildable tiles. - * Components too small to ever bring a building online are dropped. + * Components too small to ever be worth building on are dropped. + * + * `anomaly` is taken because the floor is a property of the rules in force + * rather than of the grid — see `minIslandTiles`. Under a shared cooling pool + * the islands this produces are no longer independent either, which is the + * board-level problem and not this function's. */ export function splitGridIntoIslands( grid: Tile[][], canCoolDp = true, + anomaly?: AnomalyDefinition, ): IslandSubGrid[] { if (!grid || grid.length === 0 || !grid[0] || grid[0].length === 0) return []; const originalHeight = grid.length; const originalWidth = grid[0].length; - const minTilesRequired = canCoolDp ? 2 : 3; + const minTilesRequired = minIslandTiles(canCoolDp, anomaly); + const waterAdjacent = computeWaterAdjacency(grid); // 1. Only grass is buildable; everything else is an impassable boundary. const buildable = new Uint8Array(originalWidth * originalHeight); @@ -137,6 +211,7 @@ export function splitGridIntoIslands( const subGridTiles: Tile[][] = []; const originalTileIndices = new Int32Array(subWidth * subHeight); + const subWaterAdjacent = new Uint8Array(subWidth * subHeight); for (let subY = 0; subY < subHeight; subY++) { const row: Tile[] = []; @@ -144,7 +219,11 @@ export function splitGridIntoIslands( for (let subX = 0; subX < subWidth; subX++) { const origX = padMinX + subX; const localFlat = subY * subWidth + subX; - originalTileIndices[localFlat] = origY * originalWidth + origX; + const origFlat = origY * originalWidth + origX; + originalTileIndices[localFlat] = origFlat; + // Copied from the full-grid pass rather than recomputed here: this + // window cannot see off the board. See `computeWaterAdjacency`. + subWaterAdjacent[localFlat] = waterAdjacent[origFlat]; // The board's own terrain, not a "mine / not mine" flag — that is // what `inComponent` is, and it is carried separately because a // neighbouring island's grass is real grass and still not ours. @@ -158,6 +237,7 @@ export function splitGridIntoIslands( height: subHeight, grid: subGridTiles, buildable: inComponent, + waterAdjacent: subWaterAdjacent, tileCount: component.length, originalTileIndices, }); diff --git a/packages/solver/src/solver/solver.ts b/packages/solver/src/solver/solver.ts index 145ffd2..e824995 100755 --- a/packages/solver/src/solver/solver.ts +++ b/packages/solver/src/solver/solver.ts @@ -81,9 +81,19 @@ export function planSolve( ); if (effectiveBuildings.length === 0) return null; + // Taken by id rather than as a definition, because that is also the worker + // protocol's form: an id is a string that survives any boundary, and + // `getAnomaly` is total, so one from a newer save resolves to the base rules + // instead of arriving half understood. + // + // Resolved before the split because the split reads it: a shared cooling pool + // changes the smallest patch worth keeping. + const anomaly = getAnomaly(anomalyId); + const islands = splitGridIntoIslands( grid, canCoolDirectProducer(effectiveBuildings), + anomaly, ); if (islands.length === 0) return null; @@ -93,11 +103,7 @@ export function planSolve( return { islands, effectiveBuildings, - // Taken by id rather than as a definition, because that is also the - // worker protocol's form: an id is a string that survives any - // boundary, and `getAnomaly` is total, so one from a newer save - // resolves to the base rules instead of arriving half understood. - anomaly: getAnomaly(anomalyId), + anomaly, budgetsS: grassCounts.map( (count) => timeBudgetS * (count / totalIslandGrass), ), diff --git a/packages/solver/src/solver/types.ts b/packages/solver/src/solver/types.ts index 0c9d6f9..d245ca3 100755 --- a/packages/solver/src/solver/types.ts +++ b/packages/solver/src/solver/types.ts @@ -313,6 +313,22 @@ export interface IslandSubGrid { * `originalTileIndices`. */ buildable: Uint8Array; + /** + * 1 where the tile has water among its eight neighbours, **counting off the + * board as water**. Indexed like `buildable`. + * + * Resolved on the full grid before decomposition and carried here, because it + * cannot be recovered from this window. The window's padding is clamped to + * the board, so for a tile on the board's own edge the off-board neighbour is + * simply not in it — indistinguishable from the window's own boundary. A + * shore test run over `grid` would read every border tile as inland, which on + * a custom island is its entire perimeter. + * + * Off the board is water because the game has one global map with open water + * between islands, and these boards are rectangles cut out of it. See + * `docs/game-logic.md`. + */ + waterAdjacent: Uint8Array; /** How many tiles this island has. `buildable`'s popcount, kept rather than recounted. */ tileCount: number; /** diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index 7f286a6..6c3218f 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -15,10 +15,13 @@ import { describe, expect, it } from "vitest"; import { EPS } from "../src/solver/constants"; import { buildIslandContext } from "../src/solver/context"; import { makeGrid } from "../src/grid"; +import { getAnomaly } from "../src/data/anomalies"; import { canCoolDirectProducer, + computeWaterAdjacency, countGrassTiles, estimateTotalMaxPower, + minIslandTiles, splitGridIntoIslands, } from "../src/solver/island"; import { Rng } from "../src/solver/rng"; @@ -161,6 +164,125 @@ describe("splitting a grid into islands", () => { }); }); +describe("the smallest patch worth keeping", () => { + const cryo = getAnomaly("cryo_nexus"); + + it("is 3 tiles, or 2 when one cooler can cover a direct producer", () => { + expect(minIslandTiles(false)).toBe(3); + expect(minIslandTiles(true)).toBe(2); + }); + + it("is 1 tile under a shared cooling pool, whatever the roster", () => { + // Both ordinary floors exist because cooling has to cross a tile boundary. + // Pooled, it does not: a lone tile takes a cooler that pays into the pool, + // or a direct producer the pool pays for. + expect(minIslandTiles(false, cryo)).toBe(1); + expect(minIslandTiles(true, cryo)).toBe(1); + }); + + it("is unchanged by an anomaly that does not pool cooling", () => { + expect(minIslandTiles(false, getAnomaly("tidal_ascendancy"))).toBe(3); + expect(minIslandTiles(false, getAnomaly("none"))).toBe(3); + expect(minIslandTiles(false, undefined)).toBe(3); + }); + + it("keeps the patches it otherwise drops, under a shared pool", () => { + /* + * The same board the roster above reduces to a single 3-tile island. These + * are tiles no other rule in the game can use: 21 of them across the + * shipped maps. + */ + const board = makeGrid(["G.GG.GGG"]); + + expect( + splitGridIntoIslands(board, false).map((i) => i.tileCount), + ).toEqual([3]); + expect( + splitGridIntoIslands(board, false, cryo) + .map((i) => i.tileCount) + .sort((a, b) => a - b), + ).toEqual([1, 2, 3]); + }); +}); + +describe("water adjacency", () => { + /* + * Off the board counts as water: the game has one global map on which every + * island sits in open water, and these boards are rectangles cut out of it. + * The flag has to be resolved on the full grid, because an island's window is + * padded *clamped to the board* — so a tile on the board's own edge has no + * off-board neighbour inside its window to test, and the natural + * bounds-check-and-skip would read it as inland. + */ + const flagsOf = (rows: string[]) => { + const grid = makeGrid(rows); + const width = grid[0].length; + const flags = computeWaterAdjacency(grid); + return (x: number, y: number) => flags[y * width + x]; + }; + + it("flags every tile on the board's edge", () => { + const at = flagsOf(["GGGGG", "GGGGG", "GGGGG"]); + + // The one tile with eight in-board, non-water neighbours. + expect(at(2, 1)).toBe(0); + for (const [x, y] of [ + [0, 0], + [2, 0], + [4, 2], + [0, 1], + [4, 1], + ] as const) { + expect(at(x, y), `(${x}, ${y}) is on the board's edge`).toBe(1); + } + }); + + it("flags a tile beside water, and not one beside a pond or a rock", () => { + // A pond looks wet and is filed with the rocks and the trees: an obstacle, + // and no shore bonus. Only the water tile proper counts. + const at = flagsOf([ + "RRRRRRR", + "RG.GORG", + "RGGGGRG", + "RRRRRRR", + ]); + + expect(at(1, 2), "diagonally below the water at (2, 1)").toBe(1); + expect(at(3, 2), "diagonally below the water at (2, 1)").toBe(1); + expect(at(4, 2), "beside the pond at (4, 1), which is not water").toBe(0); + }); + + it("carries the board-edge flag into a window that cannot see the edge", () => { + /* + * The trap, concretely. This island is clamped against the board's left, + * top and bottom edges, so in its own window those tiles sit at coordinate + * 0 — indistinguishable from the window boundary that padding creates + * everywhere else. Resolved from the window, every one of them would read + * as inland. + */ + const islands = splitGridIntoIslands(makeGrid(["GGGRRRR", "GGGRRRR", "GGGRRRR"])); + + expect(islands.length).toBe(1); + const island = islands[0]; + const at = (x: number, y: number) => + island.waterAdjacent[y * island.width + x]; + + // The window is padded one tile to the right only — the other three sides + // are the board's, so sub coordinates match original ones for this island. + expect(island.width).toBe(4); + expect([at(0, 0), at(1, 0), at(0, 1), at(1, 2)]).toEqual([1, 1, 1, 1]); + // The two interior tiles: eight in-board neighbours, none of them water. + expect([at(1, 1), at(2, 1)]).toEqual([0, 0]); + }); + + it("indexes like `buildable`, over the same window", () => { + const [island] = splitGridIntoIslands(makeGrid(["RRRRR", "RGGGR", "RRRRR"])); + + expect(island.waterAdjacent.length).toBe(island.buildable.length); + expect(island.waterAdjacent.length).toBe(island.width * island.height); + }); +}); + describe("coordinate remapping", () => { it("points original tile indices back at the source tiles", () => { const grid = makeGrid(["......", "..GGG.", "..GGG."]); @@ -247,6 +369,7 @@ describe("the theoretical max-power bound", () => { height: 0, grid: [], buildable: new Uint8Array(0), + waterAdjacent: new Uint8Array(0), tileCount: 0, originalTileIndices: [], }; diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index fedac80..8c43989 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -301,6 +301,7 @@ class SolverState { const islands = splitGridIntoIslands( grid, canCoolDirectProducer(effectiveBuildings), + configState.activeAnomaly, ); if (!islands.length) return 0; @@ -341,7 +342,14 @@ class SolverState { if (!effectiveBuildings.length) return 0; const hasCoolingSupport = canCoolDirectProducer(effectiveBuildings); - const subGrids = splitGridIntoIslands(grid, hasCoolingSupport); + // The same anomaly the run will be planned under: under a shared cooling + // pool the split keeps one-tile patches, and a bound that decomposed the + // board differently from the run would be measured against another board. + const subGrids = splitGridIntoIslands( + grid, + hasCoolingSupport, + configState.activeAnomaly, + ); return estimateTotalMaxPower(subGrids, effectiveBuildings); } diff --git a/src/lib/worker/variants.test.ts b/src/lib/worker/variants.test.ts index 500c89d..8ec1f69 100755 --- a/src/lib/worker/variants.test.ts +++ b/src/lib/worker/variants.test.ts @@ -51,6 +51,7 @@ function plan(): IslandPlan { height: 1, grid: [] as Tile[][], buildable: Uint8Array.from([1, 1]), + waterAdjacent: Uint8Array.from([0, 0]), tileCount: 2, originalTileIndices: [offset, offset + 1], }); From c7d7a59bc517552e1dde98d7a21dc583d7a5f67d Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:00:50 +0300 Subject: [PATCH 05/21] Implement Tidal Ascendancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first anomaly the search acts on. A terrain bonus is decided by the board alone, so `terrainScales` resolves it into a per-tile multiplier once when the context is built and `IslandContext.rate(tile, building)` is a lookup — the rule costs the search nothing, and `uniformRating` makes it the identity under every other anomaly. It is applied where a building is written onto a tile rather than where its figures are read. A step of the walk writes one or two tiles and then simulates the island, which reads every occupied tile's three figures, so the write is some 25x less work and `simulateIsland` — the hot path, and the definition of what a layout scores — is untouched. Resolution is a lookup and not a multiply because a scaled building's waste is snapToAuthoredPrecision(heat - energy), and that snap is a string round-trip. `put()` becomes the only way a building enters a placement, so the bonus cannot be missed at one of three dozen sites; a miss would be silent, the layout simply being worth less than it is. `rate` is idempotent, which is what makes that safe: the search swaps buildings between tiles and restores them when a move is rejected, handing back objects it was already given, and without it a restore would scale a scaled building. `downgradeOversized` also rates each candidate before comparing it, since the ladder is the plain roster while the building it replaces and the load it measured are in the tile's units. The readout goes with it, or the two would print different figures for the same board: `simulatePlacedBuildings` takes an anomaly, `layoutState` holds one the way it holds research, and a previewed board is rated under the anomaly its code names rather than the reader's. A bonus is genuinely not free power here. A shore generator makes x1.67 the waste while an inland cooler still covers x1, so a cluster straddling the coast goes offline, and a layout optimised under the base rules can score lower re-rated under Tidal. The search has to keep each cluster on one side of the shoreline, which is a harder problem than the uniform one: on Magma Rift a 6s run lands under the baseline and a 30s run lands 8% above it. The seeding heuristics still pick candidates by unscaled roster figures, which is where that gap lives. No fixture moves — the fixtures never carry an anomaly. --- CLAUDE.md | 41 ++++- docs/SOLVER.md | 27 +++ packages/solver/src/solver/context.ts | 160 +++++++++++++++++- packages/solver/src/solver/placementSearch.ts | 118 ++++++++----- packages/solver/tests/anomalies.test.ts | 113 ++++++++++++- src/App.svelte | 5 + src/lib/simulation/simulator.test.ts | 75 ++++++++ src/lib/simulation/simulator.ts | 45 ++++- src/lib/state/index.ts | 4 +- src/lib/state/layout.svelte.ts | 30 ++++ src/lib/state/solver.svelte.ts | 1 + 11 files changed, 558 insertions(+), 61 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d1bcfb1..39e22cc 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,12 +272,41 @@ timeline. `docs/game-logic.md` is the authority on what each one does and changes which tier a *placement* resolves to (`rebasePlacements`), while an anomaly changes none of them — the same building at the same tier is simply rated differently — so it needs the re-score alone. -- **The solver accepts an anomaly and does not yet act on one.** It is threaded - the whole way — `SolveOptions.anomalyId` / `SolveRunOptions.anomalyId`, the - worker request, and `IslandContext.anomaly`, which is where the stages will - read it because every stage already holds a context. The model, the board - window the rules need, the catalogue and the UI are in; the rules are not, and - nothing on screen says a solve ignored one. +- **Tidal Ascendancy is implemented; Cryo Nexus and Singularity Isolation are + not.** The anomaly is threaded the whole way — `SolveOptions.anomalyId` / + `SolveRunOptions.anomalyId`, the worker request, and `IslandContext.anomaly` — + and the terrain half of it is resolved into `IslandContext.rate`. The two + remaining rules need more than a per-tile multiplier: Singularity's depends on + a building's neighbours, so it changes as the search moves them, and Cryo's is + the board-level problem above. Nothing on screen says a solve ignored one. + + **A terrain bonus is resolved per tile, once, when the context is built.** + Nothing about a layout can change which tiles qualify, so `terrainScales` + settles it at construction and `ctx.rate(tile, building)` is a lookup — which + is why the rule costs the search nothing. `uniformRating` is true under every + other anomaly and `rate` is then the identity. + + It is called where a building is **written onto a tile**, never where its + 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 + 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. + + **`rate` is idempotent**, which is what makes that safe: the search swaps + buildings between tiles and restores them when a move is rejected, handing + back objects it was already given. Without it a restore would scale a scaled + building and the layout would quietly be worth 2.8x. + + **A bonus is genuinely not free power here, and it shows.** A shore generator + makes x1.67 the waste while an inland cooler still covers x1, so a cluster + straddling the shoreline goes offline — a layout optimised under the base + rules can score *lower* re-rated under Tidal. The search has to learn to keep + a cluster on one side of the coast, which is a harder problem than the uniform + one: on Magma Rift a 6s run comes back under the baseline and a 30s run comes + back 8% above it. The seeding heuristics still reason in unscaled roster + figures, which is where that gap lives. The anomaly crosses the worker boundary **as an id**, resolved again on the far side, so the message stays a string rather than a table entry that has to diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 3197731..26ad25a 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -109,6 +109,33 @@ Two things about the shapes are load-bearing: time the search moves a neighbour, so a placed building's figures have to be resolved per layout. +`terrain_affinity` is the one rule implemented. `terrainScales` resolves it into a per-tile +multiplier when the context is built — nothing about a layout can change which tiles qualify — and +`IslandContext.rate(tile, building)` is then a lookup, so the rule costs the search nothing. +`uniformRating` is true under every other anomaly and `rate` is the identity. + +Two things about where it is applied: + +- **At the write, not the read.** A step of the walk writes one or two tiles and then simulates the + island, which reads every occupied tile's three figures — so resolving at the write is some 25x + less work, and `simulateIsland` stays exactly as it was. `put()` is the only way a building + enters a placement, so the bonus cannot be missed at one of three dozen sites; a miss would be + silent, the layout simply being worth less than it is. Resolution is a lookup rather than a + multiply because a scaled building's waste is `snapToAuthoredPrecision(heat - energy)`, and that + snap is a string round-trip. +- **`rate` is idempotent.** The search swaps buildings between tiles and restores them when a move + is rejected, so it hands back objects it was already given; without this a restore would scale a + scaled building. `downgradeOversized` is the one place that also has to rate a *candidate* before + comparing it, since the ladder is the plain roster while the building it is replacing and the + load it measured are both in the tile's units. + +**A terrain bonus is a harder search, not just a bigger number.** A shore generator makes x1.67 the +waste while an inland cooler still covers x1, so a cluster straddling the coast goes offline — a +layout optimised under the base rules can score *lower* re-rated under Tidal, and the search has to +keep each cluster on one side of the shoreline. On Magma Rift a 6s run lands under the baseline and +a 30s run lands 8% above it. The seeding heuristics still pick candidates by unscaled roster +figures, which is where that gap lives. + `shared_cooling` is the odd one out entirely, and it is the one that costs this package something. **Its pool is the whole board** — the game's "island" is the map, Gale Hills and Ash Bay, and "cooling does not carry over to other islands" means it does not carry to a different diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index b2c8daa..a51c8d0 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -1,6 +1,13 @@ import { getAnomaly } from "../data/anomalies"; +import { scaleEffectiveBuilding } from "../data/effectiveBuildings"; import { CHEBYSHEV_DIRECTIONS, spatialKeyCompare } from "./constants"; -import type { AnomalyDefinition, Tile } from "./types"; +import { computeWaterAdjacency } from "./island"; +import type { + AnomalyDefinition, + EffectiveBuilding, + Tile, + TileType, +} from "./types"; /** * Scratch buffers reused by every `runDistribution` call on one island. @@ -110,11 +117,36 @@ export interface IslandContext { * to answer (a shore bonus, a shared cooling pool) will want it exactly here, * beside the tile index and the adjacency they are defined over. * - * **Nothing reads it yet.** The rules are not implemented — see - * `docs/game-logic.md`. It is threaded so that implementing them is a change - * to the stages and not to every signature between here and the worker. + * The per-tile half of it is already resolved into `rate`; the rules that + * depend on a layout rather than a tile read it here. */ readonly anomaly: AnomalyDefinition; + /** + * True when every tile on this island rates a building the same way, which is + * every anomaly but a terrain bonus. + * + * Hoisted out of `rate` so the common case is a single boolean rather than a + * per-tile array read, and so a stage can skip the call entirely where that + * reads better. + */ + readonly uniformRating: boolean; + /** + * What `building` is worth **on this tile**, which is the building itself + * unless a terrain bonus applies to it. + * + * Called where a building is written onto a tile rather than where its + * figures are read, and that is the whole performance argument: a step of the + * walk writes one or two tiles and then simulates the island, which reads + * every occupied tile's three figures. Resolving at the write is some + * twenty-five times less work, and it leaves `simulateIsland` — the hot path, + * and the definition of what a layout scores — untouched. + * + * Scaling has to be resolved rather than computed here because a scaled + * building's waste is `snapToAuthoredPrecision(heat - energy)`, and that snap + * is a string round-trip. Each distinct (tile class, building) pair is built + * once and then handed back. + */ + rate(tile: number, building: EffectiveBuilding): EffectiveBuilding; readonly xs: Int32Array; readonly ys: Int32Array; /** Chebyshev-adjacent buildable tiles of each tile, ascending index order. */ @@ -136,6 +168,73 @@ export interface IslandContext { readonly wasteTiles: Int32Array; } +/** + * The per-tile stat multiplier a terrain bonus produces, or `null` when every + * tile on this island rates the same. + * + * Indexed by context tile index, so it lines up with `xs` / `ys` and with the + * placement array rather than with the window. + * + * Resolved once per island because nothing about a layout can change it: which + * tiles qualify is decided by terrain alone. It is the cheapest possible shape + * for the rule and the reason a terrain bonus costs the search nothing. + * + * `null` rather than an array of ones so the common case — every anomaly but + * this one — is a single check instead of a per-tile read. + */ +function terrainScales( + localGrid: Tile[][], + anomaly: AnomalyDefinition, + xs: Int32Array, + ys: Int32Array, + n: number, + waterAdjacent?: Uint8Array, +): Float64Array | null { + if (anomaly.rule !== "terrain_affinity") return null; + + const height = localGrid.length; + const width = localGrid[0]?.length ?? 0; + // Off the board counts as water, so the shore mask answers the water half and + // is the only half that can see past the window. Computed here when the + // caller handed over a whole board rather than an island window. + const shore = anomaly.terrain.includes("water") + ? (waterAdjacent ?? computeWaterAdjacency(localGrid)) + : undefined; + // Everything else is ordinary terrain, which cannot lie outside the window: + // no rock or tree exists off the board. + const other = new Set( + anomaly.terrain.filter((type) => type !== "water"), + ); + + const scales = new Float64Array(n); + let anyScaled = false; + + for (let i = 0; i < n; i++) { + const x = xs[i]; + const y = ys[i]; + let qualifies = shore !== undefined && shore[y * width + x] === 1; + + if (!qualifies && other.size > 0) { + for (const [dx, dy] of CHEBYSHEV_DIRECTIONS) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + if (other.has(localGrid[ny][nx].type)) { + qualifies = true; + break; + } + } + } + + scales[i] = qualifies ? anomaly.multiplier : 1; + if (qualifies) anyScaled = true; + } + + // An island wholly inland under a shore bonus rates like any other: say so, + // and every tile of it skips the lookup. + return anyScaled ? scales : null; +} + /** * Builds the tile index and adjacency for one island's buildable tiles. * @@ -145,11 +244,20 @@ export interface IslandContext { * and is grass, and without the mask those tiles would join this island's * index. Omitted, every grass tile counts — which is what a caller handing over * a whole grid of its own means. + * + * `waterAdjacent` is the same window's shore mask, and a terrain bonus needs + * it: off the board counts as water, and an island window is clamped to the + * board, so a tile on the board's own edge has no off-board neighbour inside + * the window to find. Omitted, it is computed from `localGrid` with everything + * outside it treated as water — which is exactly right for a caller handing + * over the whole board, and too generous for one handing over an island window, + * so `IslandSubGrid` carries a mask and both island callers pass it. */ export function buildIslandContext( localGrid: Tile[][], buildable?: Uint8Array, anomaly: AnomalyDefinition = getAnomaly(undefined), + waterAdjacent?: Uint8Array, ): IslandContext { const gridWidth = localGrid[0]?.length ?? 0; const coords: [number, number][] = []; @@ -195,9 +303,53 @@ export function buildIslandContext( neighbors[i] = Int32Array.from(found); } + const tileScale = terrainScales( + localGrid, + anomaly, + xs, + ys, + n, + waterAdjacent, + ); + const uniformRating = tileScale === null; + // One cache per distinct scale, so a building is built at a given rating + // once per island rather than once per placement. Keyed on the base object + // because the roster is shared by every tile and its entries are stable for + // the whole solve. + const rated = new Map>(); + // Every scaled variant back to the roster entry it came from, so `rate` can + // be applied to a building that already carries a rating. The search swaps + // buildings between tiles and restores them afterwards, so it hands back + // objects it was given — without this, a swap would scale a scaled building + // and the layout would quietly be worth 2.8x. + const baseOf = new Map(); + return { n, anomaly, + uniformRating, + rate(tile: number, building: EffectiveBuilding): EffectiveBuilding { + if (tileScale === null) return building; + // Idempotent: a building arriving with another tile's rating is taken + // back to its roster entry first, so this is a function of the tile + // rather than of how many times it has been applied. + const base = baseOf.get(building) ?? building; + const scale = tileScale[tile]; + if (scale === 1) return base; + + let byBuilding = rated.get(scale); + if (byBuilding === undefined) { + byBuilding = new Map(); + rated.set(scale, byBuilding); + } + let scaled = byBuilding.get(base); + if (scaled === undefined) { + scaled = scaleEffectiveBuilding(base, scale); + byBuilding.set(base, scaled); + baseOf.set(scaled, base); + } + return scaled; + }, xs, ys, neighbors, diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index 3900dbf..e2cc405 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -114,6 +114,28 @@ 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] = 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]; } @@ -427,22 +449,22 @@ function bestSharedReactorFit( const coolerPositions = leftover.slice(0, c); const touched: number[] = []; - scratch[tile] = topReactor; + put(scratch, ctx, tile, topReactor); touched.push(tile); for (const n of extraTopPositions) { - scratch[n] = topReactor; + put(scratch, ctx, n, topReactor); touched.push(n); } for (const n of extraSecondPositions) { - scratch[n] = secondReactor; + put(scratch, ctx, n, secondReactor); touched.push(n); } for (const n of genPositions) { - scratch[n] = generator; + put(scratch, ctx, n, generator); touched.push(n); } for (const n of coolerPositions) { - scratch[n] = cooler; + put(scratch, ctx, n, cooler); touched.push(n); } @@ -635,7 +657,7 @@ function constructSeed( const claimed: number[] = []; if (best.kind === "hub") { const { generator, reactor, r, c } = best.hub!; - placement[best.tile] = generator; + put(placement, ctx, best.tile, generator); claimed.push(best.tile); for (let i = 0; i < r; i++) { placement[best.neighbors[i]] = reactor; @@ -652,16 +674,16 @@ function constructSeed( claimed.push(shared.reactorTiles[i]); } for (const n of shared.genPositions) { - placement[n] = topGenerator; + put(placement, ctx, n, topGenerator); claimed.push(n); } for (const n of shared.coolerPositions) { - placement[n] = topCooler; + put(placement, ctx, n, topCooler); claimed.push(n); } } else { const { dp, c } = best.dp!; - placement[best.tile] = dp; + put(placement, ctx, best.tile, dp); claimed.push(best.tile); for (let i = 0; i < c; i++) { placement[best.neighbors[i]] = topCooler; @@ -972,10 +994,10 @@ async function hillClimb( const tile = adj[rng.int(adj.length)]; const old = current[tile]; if (old !== topCooler) { - current[tile] = topCooler; + put(current, ctx, tile, topCooler); const trial = simulateIsland(current, ctx); if (!accept(trial.totalPower, trial.placements)) - current[tile] = old; + put(current, ctx, tile, old); continue; } } @@ -990,10 +1012,10 @@ async function hillClimb( const tile = adj[rng.int(adj.length)]; const old = current[tile]; if (old !== topGenerator) { - current[tile] = topGenerator; + put(current, ctx, tile, topGenerator); const trial = simulateIsland(current, ctx); if (!accept(trial.totalPower, trial.placements)) - current[tile] = old; + put(current, ctx, tile, old); continue; } } @@ -1015,13 +1037,13 @@ async function hillClimb( const val2 = current[tile2]; if (val1 === val2) continue; - current[tile1] = val2; - current[tile2] = val1; + put(current, ctx, tile1, val2); + put(current, ctx, tile2, val1); const trial = simulateIsland(current, ctx); if (!accept(trial.totalPower, trial.placements)) { - current[tile1] = val1; - current[tile2] = val2; + put(current, ctx, tile1, val1); + put(current, ctx, tile2, val2); } continue; } @@ -1031,7 +1053,7 @@ async function hillClimb( const replacement = randomCandidate(pools, rng); if (replacement === old) continue; - current[tile] = replacement; + put(current, ctx, tile, replacement); const trial = simulateIsland(current, ctx); if (!accept(trial.totalPower, trial.placements)) current[tile] = old; } @@ -1229,7 +1251,7 @@ async function arrangeComposition( if (held === null || !surplus.has(held.id)) continue; current[tile] = null; const power = simulateIsland(current, ctx).totalPower; - current[tile] = held; + put(current, ctx, tile, held); if (power > bestPower) { bestPower = power; bestMove = tile; @@ -1241,7 +1263,7 @@ async function arrangeComposition( const building = byId.get(targetId)!; for (let tile = 0; tile < ctx.n; tile++) { if (current[tile] !== null) continue; - current[tile] = building; + put(current, ctx, tile, building); const power = simulateIsland(current, ctx).totalPower; current[tile] = null; if (power > bestPower) { @@ -1253,9 +1275,9 @@ async function arrangeComposition( for (let tile = 0; tile < ctx.n; tile++) { const held = current[tile]; if (held === null || !surplus.has(held.id)) continue; - current[tile] = building; + put(current, ctx, tile, building); const power = simulateIsland(current, ctx).totalPower; - current[tile] = held; + put(current, ctx, tile, held); if (power > bestPower) { bestPower = power; bestMove = tile; @@ -1263,7 +1285,7 @@ async function arrangeComposition( } } if (bestMove < 0) return null; - current[bestMove] = building; + put(current, ctx, bestMove, building); } const now = performance.now(); @@ -1316,8 +1338,8 @@ async function arrangeComposition( const valA = current[a]; const valB = current[b]; - current[a] = valB; - current[b] = valA; + put(current, ctx, a, valB); + put(current, ctx, b, valA); const trial = simulateIsland(current, ctx); if ( @@ -1328,15 +1350,15 @@ async function arrangeComposition( bestSwap = [a, b]; } - current[a] = valA; - current[b] = valB; + put(current, ctx, a, valA); + put(current, ctx, b, valB); } if (bestSwap === null) break; const [a, b] = bestSwap; const held = current[a]; - current[a] = current[b]; - current[b] = held; + put(current, ctx, a, current[b]); + put(current, ctx, b, held); } return current; @@ -1418,7 +1440,7 @@ async function greedyPolish( for (const building of candidates) { if (building === held) continue; - current[tile] = building; + put(current, ctx, tile, building); const trial = simulateIsland(current, ctx); if ( trial.totalPower - bestPower > bestGain && @@ -1428,12 +1450,12 @@ async function greedyPolish( bestTile = tile; bestBuilding = building; } - current[tile] = held; + put(current, ctx, tile, held); } } if (bestTile < 0) break; - current[bestTile] = bestBuilding; + put(current, ctx, bestTile, bestBuilding); bestPower += bestGain; } @@ -1497,7 +1519,7 @@ function pruneDeadWeight( bestPower = trial.totalPower; rows = trial.placements; } else { - current[tile] = held; + put(current, ctx, tile, held); } } @@ -1608,7 +1630,7 @@ export function downgradeOversized( // 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 }; - current[row.idx] = building; + put(current, ctx, row.idx, building); occupied.push(row.idx); } occupied.sort((a, b) => a - b); @@ -1628,11 +1650,17 @@ export function downgradeOversized( 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. - if (candidate.effectiveValue >= building.effectiveValue) break; - if (!covers(candidate.effectiveValue, load)) continue; + if (rated.effectiveValue >= building.effectiveValue) break; + if (!covers(rated.effectiveValue, load)) continue; - current[tile] = candidate; + put(current, ctx, tile, rated); const trial = simulateIsland(current, ctx); if ( @@ -1647,7 +1675,7 @@ export function downgradeOversized( break; } - current[tile] = building; + put(current, ctx, tile, building); } } } @@ -1754,7 +1782,12 @@ export async function replayIslandDeterministic( ): Promise { // No anomaly, and never one: the fixtures are a determinism harness for the // search itself, and a rule change is a different question asked of it. - const ctx = buildIslandContext(island.grid, island.buildable); + const ctx = buildIslandContext( + island.grid, + island.buildable, + undefined, + island.waterAdjacent, + ); if (ctx.n === 0) return { placements: [], powerOutput: 0.0 }; const reactors = effectiveBuildings @@ -1817,7 +1850,12 @@ export async function solveIsland( rngSeed?: number, anomaly?: AnomalyDefinition, ): Promise { - const ctx = buildIslandContext(island.grid, island.buildable, anomaly); + const ctx = buildIslandContext( + island.grid, + island.buildable, + anomaly, + island.waterAdjacent, + ); if (ctx.n === 0) return { placements: [], powerOutput: 0.0 }; const reactors = effectiveBuildings diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts index 145d159..7f0d0fb 100644 --- a/packages/solver/tests/anomalies.test.ts +++ b/packages/solver/tests/anomalies.test.ts @@ -18,7 +18,11 @@ import { } from "../src/data/anomalies"; import { scaleEffectiveBuilding } from "../src/data/effectiveBuildings"; import { wasteIsCovered } from "../src/solver/physics"; -import type { EffectiveBuilding } from "../src/solver/types"; +import { buildIslandContext } from "../src/solver/context"; +import { simulateIsland } from "../src/solver/simulate"; +import { makeGrid } from "../src/grid"; +import { basicRoster, cooler } from "./helpers"; +import type { EffectiveBuilding, Placement } from "../src/solver/types"; const generator: EffectiveBuilding = { id: "gen", @@ -102,3 +106,110 @@ describe("scaling a resolved building", () => { ); }); }); + +describe("a terrain bonus reaching the board", () => { + /* + * Tidal Ascendancy is the only rule in the game where an unbuildable tile + * does anything, and the only one resolved per tile rather than per roster or + * per layout. Which tiles qualify is fixed by the terrain, so the context + * settles it once and `rate` is a lookup — the reason a shore bonus costs the + * search nothing. + */ + const tidal = getAnomaly("tidal_ascendancy"); + + /** The context for a board, with off-board treated as water throughout. */ + const contextFor = (rows: string[], anomaly = tidal) => + buildIslandContext(makeGrid(rows), undefined, anomaly); + + it("rates a shore tile up and an inland tile as authored", () => { + // A 5x3 of grass ringed by nothing: every tile but the middle row's + // interior touches the board's edge, and the edge is water. + const ctx = contextFor(["GGGGG", "GGGGG", "GGGGG"]); + const base = cooler(100); + const inland = ctx.tiles.find((t) => ctx.rate(t, base) === base)!; + + expect(ctx.uniformRating).toBe(false); + expect(ctx.rate(ctx.tiles[0], base).effectiveValue).toBe(167); + expect(ctx.rate(inland, base).effectiveValue).toBe(100); + // The authored tier value never moves, whatever the tile. + expect(ctx.rate(ctx.tiles[0], base).baseValue).toBe(100); + }); + + it("rates every tile of a wholly inland island the same", () => { + // Walled in by rock, well away from the board's edge. Nothing qualifies, so + // the context says so and every tile skips the lookup. + const ctx = contextFor([ + "RRRRRRR", + "RRRRRRR", + "RRGGGRR", + "RRRRRRR", + "RRRRRRR", + ]); + + expect(ctx.uniformRating).toBe(true); + const base = cooler(100); + expect(ctx.rate(ctx.tiles[0], base)).toBe(base); + }); + + it("is idempotent, so a building may be moved between tiles", () => { + /* + * The search swaps two buildings and restores them when the move is + * rejected, which hands `rate` objects it has already rated. Without this + * the restore would scale a scaled building and the layout would quietly be + * worth 2.8x. + */ + const ctx = contextFor(["GGGGG", "GGGGG", "GGGGG"]); + const base = cooler(100); + const inland = ctx.tiles.find((t) => ctx.rate(t, base) === base)!; + const shore = ctx.tiles[0]; + + const onShore = ctx.rate(shore, base); + expect(onShore.effectiveValue).toBe(167); + // Shore -> shore, twice over. + expect(ctx.rate(shore, onShore)).toBe(onShore); + // Shore -> inland gives the authored building back, not 167 x 1.67. + expect(ctx.rate(inland, onShore)).toBe(base); + // And back again. + expect(ctx.rate(shore, ctx.rate(inland, onShore))).toBe(onShore); + }); + + it("leaves every tile alone under an anomaly that is not terrain-based", () => { + for (const id of ["none", "cryo_nexus", "singularity_isolation"]) { + const ctx = contextFor(["GGGGG", "GGGGG"], getAnomaly(id)); + expect(ctx.uniformRating, id).toBe(true); + } + }); + + it("scores a shore layout above the same layout inland", () => { + /* + * The rule end to end. The same three buildings, the same arrangement, on + * a board where they are coastal and on one where they are not — and the + * bonus is not free power, because the cooler has to be bonused too for the + * generator's larger waste to stay covered. + */ + const roster = basicRoster(); + const layoutOn = (rows: string[]) => { + const ctx = buildIslandContext(makeGrid(rows), undefined, tidal); + const placement: Placement = new Array(ctx.n).fill(null); + const [r, g, c] = [roster[0], roster[1], roster[2]]; + placement[ctx.tiles[0]] = ctx.rate(ctx.tiles[0], r); + placement[ctx.tiles[1]] = ctx.rate(ctx.tiles[1], g); + placement[ctx.tiles[2]] = ctx.rate(ctx.tiles[2], c); + return simulateIsland(placement, ctx).totalPower; + }; + + // Three tiles in a column against the board's left edge: all shore. + const shore = layoutOn(["GRRRR", "GRRRR", "GRRRR"]); + // The same column, walled in by rock and away from every edge. + const inland = layoutOn([ + "RRRRR", + "RRGRR", + "RRGRR", + "RRGRR", + "RRRRR", + ]); + + expect(inland).toBeGreaterThan(0); + expect(shore).toBeCloseTo(inland * 1.67, 6); + }); +}); diff --git a/src/App.svelte b/src/App.svelte index 0335a8a..b9ff612 100755 --- a/src/App.svelte +++ b/src/App.svelte @@ -201,6 +201,7 @@ const rosterChanged = signature !== previousRoster; const prestigeChanged = prestige !== previousPrestige; + const anomalyChanged = anomaly !== previousAnomaly; previousRoster = signature; previousAnomaly = anomaly; previousPrestige = prestige; @@ -211,6 +212,10 @@ // tier is worth — so the board is re-rated rather than re-based, and // `setPrestige` re-scores on its own. if (prestigeChanged) layoutState.setPrestige(prestige); + // Pushed for the same reason and by the same route: a terrain bonus is + // resolved per tile when the board is scored, so the readout has to be + // rating the board under the timeline the search is. + if (anomalyChanged) layoutState.setAnomaly(configState.activeAnomaly); solverState.rescoreResult(); }); diff --git a/src/lib/simulation/simulator.test.ts b/src/lib/simulation/simulator.test.ts index 4273b2c..8e49b81 100755 --- a/src/lib/simulation/simulator.test.ts +++ b/src/lib/simulation/simulator.test.ts @@ -24,6 +24,7 @@ */ import { describe, it, expect } from "vitest"; import { simulatePlacedBuildings } from "./simulator"; +import { getAnomaly } from "@reactor2/solver"; import { buildIslandContext } from "@reactor2/solver"; import { splitGridIntoIslands } from "@reactor2/solver"; import { simulateIsland } from "@reactor2/solver"; @@ -31,6 +32,7 @@ import { Rng } from "@reactor2/solver"; import type { BuildingDefinition, EffectiveBuilding, + PlacedBuilding, Placement, Tile, } from "@reactor2/solver"; @@ -348,3 +350,76 @@ describe("hand-placed scorer matches the solver", () => { expect(compared).toBeGreaterThan(150); }); }); + +describe("the readout rates a board under the same rules the search does", () => { + /* + * The whole reason this module delegates: a terrain bonus is resolved per + * tile when a board is scored, so if the scorer behind the readout did not + * take the anomaly, a shore layout would print its authored figures while the + * solver's identical layout printed bonused ones — the two disagreeing about + * the same board, which is the one thing this arrangement exists to prevent. + */ + const tidal = getAnomaly("tidal_ascendancy"); + + /** A reactor, generator and cooler in a row, with their tiles. */ + const chain = (x: number, y: number): Spec => [ + [x, y, "reactor", VALUES.reactor], + [x + 1, y, "generator", VALUES.generator], + [x + 2, y, "cooler", VALUES.cooler], + ]; + + /** A board walled in by rock, so no tile of it is on the board's edge. */ + const inlandGrid = (w: number, h: number): Tile[][] => + Array.from({ length: h }, (_, y) => + Array.from({ length: w }, (_, x) => ({ + x, + y, + type: + x === 0 || y === 0 || x === w - 1 || y === h - 1 + ? ("rock" as const) + : ("grass" as const), + })), + ); + + const powerOf = (rows: PlacedBuilding[]) => + rows.reduce((sum, r) => sum + r.powerGenerated, 0); + + it("bonuses a shore layout and leaves an inland one alone", () => { + // Every tile of a bare board is on its edge, and off the edge is water. + const shore = simulatePlacedBuildings( + grassGrid(5, 1), + DEFS, + place(chain(0, 0)), + undefined, + tidal, + ); + const inland = simulatePlacedBuildings( + inlandGrid(7, 3), + DEFS, + place(chain(2, 1)), + undefined, + tidal, + ); + + expect(powerOf(inland)).toBeGreaterThan(0); + expect(powerOf(shore)).toBeCloseTo(powerOf(inland) * 1.67, 6); + }); + + it("changes nothing when no anomaly is passed", () => { + // The default, and every board in the rest of this file. + const withNone = simulatePlacedBuildings( + grassGrid(5, 1), + DEFS, + place(chain(0, 0)), + ); + const inland = simulatePlacedBuildings( + inlandGrid(7, 3), + DEFS, + place(chain(2, 1)), + undefined, + tidal, + ); + + expect(powerOf(withNone)).toBeCloseTo(powerOf(inland), 6); + }); +}); diff --git a/src/lib/simulation/simulator.ts b/src/lib/simulation/simulator.ts index 0add2e3..83d7471 100755 --- a/src/lib/simulation/simulator.ts +++ b/src/lib/simulation/simulator.ts @@ -1,7 +1,12 @@ import { buildIslandContext, type IslandContext } from "@reactor2/solver"; import { simulateIsland } from "@reactor2/solver"; import type { Placement } from "@reactor2/solver"; -import { effectiveAtValue, type PrestigeScales } from "@reactor2/solver"; +import { + effectiveAtValue, + getAnomaly, + type AnomalyDefinition, + type PrestigeScales, +} from "@reactor2/solver"; import { unscoredPlacement } from "../data/placements"; import type { BuildingDefinition, PlacedBuilding, Tile } from "../types"; @@ -30,8 +35,12 @@ import type { BuildingDefinition, PlacedBuilding, Tile } from "../types"; * mutates a tile in place, so the grid array's identity alone would go on * matching a board that has changed underneath it. */ -let cached: { grid: Tile[][]; signature: number; ctx: IslandContext } | null = - null; +let cached: { + grid: Tile[][]; + signature: number; + anomaly: AnomalyDefinition; + ctx: IslandContext; +} | null = null; function terrainSignature(grid: Tile[][]): number { let h = 0x811c9dc5; @@ -46,13 +55,27 @@ function terrainSignature(grid: Tile[][]): number { return h; } -function contextFor(grid: Tile[][]): IslandContext { +function contextFor( + grid: Tile[][], + anomaly: AnomalyDefinition, +): IslandContext { const signature = terrainSignature(grid); - if (cached && cached.grid === grid && cached.signature === signature) { + if ( + cached && + cached.grid === grid && + cached.signature === signature && + // Part of the key, not a detail: a terrain bonus is resolved per tile when + // the context is built, so a cached one carries the rules it was built + // under and would go on rating the board under a timeline it has left. + cached.anomaly === anomaly + ) { return cached.ctx; } - const ctx = buildIslandContext(grid); - cached = { grid, signature, ctx }; + // No shore mask: this is the whole board, so its window has no edge the board + // does not, and `buildIslandContext` resolves one that treats off the board + // as water. See `computeWaterAdjacency`. + const ctx = buildIslandContext(grid, undefined, anomaly); + cached = { grid, signature, anomaly, ctx }; return ctx; } @@ -66,6 +89,7 @@ export function simulatePlacedBuildings( buildings: readonly BuildingDefinition[], placedBuildings: PlacedBuilding[], prestige?: PrestigeScales, + anomaly: AnomalyDefinition = getAnomaly(undefined), ): PlacedBuilding[] { // Copies, with every derived figure reset: this returns a fresh set of rows // rather than writing through to the caller's, and a figure left over from a @@ -78,7 +102,7 @@ export function simulatePlacedBuildings( return results; } - const ctx = contextFor(grid); + const ctx = contextFor(grid, anomaly); if (ctx.n === 0) return results; // Tile index by position, so a placement's (x, y) finds its slot. @@ -98,7 +122,10 @@ export function simulatePlacedBuildings( if (t === undefined) continue; // Two placements on one tile is not a state the editor can produce; if a // save carries one anyway, the later one stands. - placement[t] = effectiveAtValue(def, pb.baseValue, prestige); + // Rated for the tile it sits on, the same way the search rates what it + // places: under a shore bonus the two would otherwise print different + // figures for the identical board. + placement[t] = ctx.rate(t, effectiveAtValue(def, pb.baseValue, prestige)); rowOf[t] = i; } diff --git a/src/lib/state/index.ts b/src/lib/state/index.ts index 254b149..6408eee 100755 --- a/src/lib/state/index.ts +++ b/src/lib/state/index.ts @@ -47,8 +47,10 @@ import { solverState } from "./solver.svelte"; */ export async function hydrateState(): Promise { // Before the board exists, so the first score every placement gets is already - // under the player's research rather than a frame of unresearched figures. + // under the player's research and timeline rather than a frame of + // unresearched figures under no anomaly. layoutState.setPrestige(configState.prestige); + layoutState.setAnomaly(configState.activeAnomaly); await layoutState.hydrate(configState.buildingUpgrades); const shared = readSharedCode(); diff --git a/src/lib/state/layout.svelte.ts b/src/lib/state/layout.svelte.ts index 4a6c47b..198f238 100755 --- a/src/lib/state/layout.svelte.ts +++ b/src/lib/state/layout.svelte.ts @@ -16,8 +16,10 @@ import { blueprintKey, decodeBlueprint, encodeBlueprint, + getAnomaly, placementTiers, prestigeScales, + type AnomalyDefinition, type BlueprintRules, type DecodedBlueprint, type PrestigeScales, @@ -1076,6 +1078,7 @@ class LayoutState { // the board unresearched, which is the honest reading of silence and // never the reader's own. this.isPreview ? this.#previewPrestige : this.#prestige, + this.placementAnomaly, ); } @@ -1126,6 +1129,33 @@ class LayoutState { this.#prestige = scales; this.recalculate(); } + + /** + * The anomaly every placement is rated under. Held rather than read, for the + * same reason as `#prestige`. + */ + #anomaly: AnomalyDefinition = getAnomaly(undefined); + + /** + * The anomaly the board **on screen** is rated under — the author's on a + * previewed board, the player's otherwise. + * + * A previewed board sits the player's timeline out exactly as it sits their + * research out: the code says which anomaly built it, and a code that does + * not say rates it under none. Taking the reader's own would rate someone + * else's shoreline under rules it was never laid out for. + */ + get placementAnomaly(): AnomalyDefinition { + return this.isPreview + ? getAnomaly(this.#previewRules?.anomalyId ?? undefined) + : this.#anomaly; + } + + setAnomaly(anomaly: AnomalyDefinition) { + if (this.#anomaly === anomaly) return; + this.#anomaly = anomaly; + this.recalculate(); + } } export const layoutState = new LayoutState(); diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index 8c43989..671c3d9 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -475,6 +475,7 @@ class SolverState { BUILDINGS, placements, configState.prestige, + configState.activeAnomaly, ); let totalPower = 0; From 2b9bde46664b15f94130354caaa7f93be644c3b6 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:22:32 +0300 Subject: [PATCH 06/21] Add --anomaly to the CLI, and teach the bound about one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag threads an anomaly through both CLI axes — the per-island pool and the per-attempt session — and validates the id against ANOMALIES rather than leaving it to getAnomaly, which is total and would silently run the base rules on a typo. Fine for an id off localStorage, useless for one a person just typed. A run names its anomaly in the summary when it has one, since the power figures mean something different under each set of rules and a solves/ directory of codes otherwise says nothing about which is which. Writing it surfaced a bug in yesterday's Tidal measurement: `solve` takes an options object, not a positional anomalyId, so the scratch benchmark passed it as an ignored seventh argument and both runs were unmodified. The real figures, through the CLI on Magma Rift at 20s with a fixed seed: 142AC baseline against 181AC under Tidal. That run also reported 119.9% layout efficiency, which is a bound that can be beaten and therefore not a bound. `estimateTotalMaxPower` now takes the anomaly and rates each island at its best tile: the estimate is homogeneous in the roster's figures, so rating a whole island at its best tile dominates rating each tile at its own. Loose where only part of an island qualifies, which is the right way to be wrong — a bound that can be beaten is worthless, one that is generous only makes the efficiency figure read low. Magma Rift now reads 72.7%. --- CLAUDE.md | 24 +++++++---- docs/SOLVER.md | 14 ++++-- packages/solver/bin/solve.ts | 53 ++++++++++++++++++++++- packages/solver/bin/worker.ts | 7 ++- packages/solver/src/solver/island.ts | 47 +++++++++++++++++++- packages/solver/src/solver/solver.ts | 6 ++- packages/solver/tests/island.test.ts | 64 ++++++++++++++++++++++++++++ src/lib/state/solver.svelte.ts | 6 ++- 8 files changed, 203 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 39e22cc..139f6f2 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,14 +299,22 @@ timeline. `docs/game-logic.md` is the authority on what each one does and back objects it was already given. Without it a restore would scale a scaled building and the layout would quietly be worth 2.8x. - **A bonus is genuinely not free power here, and it shows.** A shore generator - makes x1.67 the waste while an inland cooler still covers x1, so a cluster - straddling the shoreline goes offline — a layout optimised under the base - rules can score *lower* re-rated under Tidal. The search has to learn to keep - a cluster on one side of the coast, which is a harder problem than the uniform - one: on Magma Rift a 6s run comes back under the baseline and a 30s run comes - back 8% above it. The seeding heuristics still reason in unscaled roster - figures, which is where that gap lives. + **A bonus is genuinely not free power here.** A shore generator makes x1.67 + the waste while an inland cooler still covers x1, so a cluster straddling the + shoreline goes offline — a layout optimised under the base rules scores + *lower* re-rated under Tidal. The search has to keep a cluster on one side of + the coast, which is a harder problem than the uniform one. On Magma Rift at + 20s, `--anomaly tidal_ascendancy` returns 181AC against the baseline's 142AC. + The seeding heuristics still pick candidates by unscaled roster figures, which + is the remaining gap. + + **`estimateTotalMaxPower` takes the anomaly, and has to.** A terrain bonus + rates some tiles above the roster, so a bound computed on the plain roster is + one a real layout walks past — Magma Rift reported 119.9% layout efficiency + before it did. The bound rates a whole island at its best tile + (`islandMaxScale`), which is loose where only part of it qualifies and is the + right trade: a bound that can be beaten is worthless, one that is generous + only makes the efficiency figure read low. The anomaly crosses the worker boundary **as an id**, resolved again on the far side, so the message stays a string rather than a table entry that has to diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 26ad25a..6e50228 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -131,10 +131,16 @@ Two things about where it is applied: **A terrain bonus is a harder search, not just a bigger number.** A shore generator makes x1.67 the waste while an inland cooler still covers x1, so a cluster straddling the coast goes offline — a -layout optimised under the base rules can score *lower* re-rated under Tidal, and the search has to -keep each cluster on one side of the shoreline. On Magma Rift a 6s run lands under the baseline and -a 30s run lands 8% above it. The seeding heuristics still pick candidates by unscaled roster -figures, which is where that gap lives. +layout optimised under the base rules scores *lower* re-rated under Tidal, and the search has to +keep each cluster on one side of the shoreline. On Magma Rift at 20s, +`npm run solve -- --map 3 --anomaly tidal_ascendancy` returns 181AC against the baseline's 142AC. +The seeding heuristics still pick candidates by unscaled roster figures, which is the remaining gap. + +**The bound takes the anomaly too.** `estimateTotalMaxPower` rates each island at its best tile +(`islandMaxScale`), because a bound computed on the plain roster is one a bonused layout walks +past: Magma Rift reported 119.9% layout efficiency before it did, and nothing else in the solver is +entitled to assume the bound holds either. It is loose where only part of an island qualifies, +which is the right way to be wrong. `shared_cooling` is the odd one out entirely, and it is the one that costs this package something. **Its pool is the whole board** — the game's "island" is the map, Gale Hills and Ash diff --git a/packages/solver/bin/solve.ts b/packages/solver/bin/solve.ts index fb6d6dd..4eaa8d2 100644 --- a/packages/solver/bin/solve.ts +++ b/packages/solver/bin/solve.ts @@ -31,6 +31,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + ANOMALIES, + DEFAULT_ANOMALY_ID, + getAnomaly, +} from "../src/data/anomalies"; import { BUILDINGS, allUpgradesUnlocked } from "../src/data/buildings"; import { getEffectiveBuildings } from "../src/data/effectiveBuildings"; import { @@ -46,6 +51,7 @@ import { solve, } from "../src/solver/solver"; import type { + AnomalyId, IslandLayout, OptimizationResult, Tile, @@ -143,6 +149,7 @@ async function solveWithPool( unlockedUpgrades: Record, timeBudgetS: number, seed: number | undefined, + anomalyId: AnomalyId | undefined, pool: WorkerPool | null, ): Promise { const plan = planSolve( @@ -151,6 +158,7 @@ async function solveWithPool( unlockedUpgrades, timeBudgetS, seed, + anomalyId, ); if (!plan || plan.islands.length < 2 || !pool || pool.size < 2) return solve( @@ -158,7 +166,7 @@ async function solveWithPool( [...BUILDINGS], unlockedUpgrades, timeBudgetS, - undefined, + { anomalyId }, seed, ); @@ -176,6 +184,10 @@ async function solveWithPool( effectiveBuildings: plan.effectiveBuildings, budgetS: plan.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 + // the same string. + anomaly: plan.anomaly, }, })), (i, result) => { @@ -205,6 +217,7 @@ async function solveMap( label: string, filenameFor: (run: SolveRun, bestPower: number) => string, seed: number | undefined, + anomalyId: AnomalyId | undefined, pool: WorkerPool | null, ): Promise { const { grid } = await decodeBlueprint(gameMap.code); @@ -219,6 +232,7 @@ async function solveMap( unlockedUpgrades, timeLimitS, offsetSeed(seed, runNum - 1), + anomalyId, pool, ); results.push({ @@ -246,6 +260,7 @@ async function runSingle( unlockedUpgrades: Record, timeLimitS: number, seed: number | undefined, + anomalyId: AnomalyId | undefined, pool: WorkerPool | null, ): Promise { const testId = nextOutputId(TEST_FILENAME_RE); @@ -257,10 +272,17 @@ async function runSingle( `Solving map ${gameMap.num} (test ID: ${testId})`, () => `test_${testId}.txt`, seed, + anomalyId, pool, ); const run = runs[0]; + // Named only when there is one, so the ordinary line-up is unchanged. The + // power figures mean something different under each set of rules, and a + // `solves/` directory of codes solved under three timelines otherwise says + // nothing about which is which. + if (anomalyId && anomalyId !== DEFAULT_ANOMALY_ID) + console.log(`Anomaly: ${getAnomaly(anomalyId).name}`); printSummary(run.result, run.elapsedS); console.log(`Blueprint: ${run.code}`); console.log( @@ -274,6 +296,7 @@ async function runComparison( runs: number, timeLimitS: number, seed: number | undefined, + anomalyId: AnomalyId | undefined, pool: WorkerPool | null, ): Promise { const sessionId = nextOutputId(ISLAND_FILENAME_RE); @@ -295,6 +318,7 @@ async function runComparison( `Map ${gameMap.num} [${idx + 1}/${gameMaps.length}]`, filenameFor, seed, + anomalyId, pool, ); @@ -361,6 +385,7 @@ async function runSession( unlockedUpgrades, timeLimitS: args.timeLimitS, seed: offsetSeed(args.seed, i), + anomalyId: args.anomalyId, }, })), (attempt, raw) => { @@ -427,10 +452,12 @@ interface Args { top: number; workers: number | undefined; seed: number | undefined; + anomalyId: AnomalyId | undefined; } const USAGE = `usage: solve [-h] [--map NUM] [--all] [--runs RUNS] [--time TIME_LIMIT_S] [--attempts N] [--top K] [--workers N] [--seed SEED] + [--anomaly ID] Decode a map, solve it, and report on the result. @@ -448,6 +475,10 @@ options: --seed SEED base seed for the search's random stream; narrows run-to-run variance for debugging (stage deadlines remain wall-clock, so runs are not bit-identical) + --anomaly ID the timeline's anomaly (default: ${DEFAULT_ANOMALY_ID}). + One of: ${ANOMALIES.map((a) => a.id).join(", ")}. + Only the rules the solver implements take effect; the rest are + accepted and ignored, exactly as in the app. This renders no picture. Each run writes its blueprint code to solves/, named after what it found; paste one into the app's Import to see the board.`; @@ -462,6 +493,7 @@ function parseArgs(argv: string[]): Args | null { top: DEFAULT_TOP_N, workers: undefined, seed: undefined, + anomalyId: undefined, }; const takeValue = ( @@ -533,6 +565,23 @@ function parseArgs(argv: string[]): Args | null { [raw, i] = takeValue(flag, inline, i); args.seed = asInt(flag, raw); break; + case "--anomaly": + [raw, i] = takeValue(flag, inline, i); + // Checked here rather than left to `getAnomaly`, which is total and + // would silently run the base rules on a typo — fine for an id off + // localStorage, useless for one a person just typed. + { + const match = ANOMALIES.find((a) => a.id === raw); + if (!match) { + throw new Error( + `unknown anomaly '${raw}'. One of: ${ANOMALIES.map((a) => a.id).join(", ")}`, + ); + } + // Taken off the table entry rather than off the argument, so what is + // carried is an `AnomalyId` and not a string that happens to match. + args.anomalyId = match.id; + } + break; default: throw new Error(`unrecognized arguments: ${token}`); } @@ -590,6 +639,7 @@ export async function main(argv: string[]): Promise { args.runs, args.timeLimitS, args.seed, + args.anomalyId, pool, ); else @@ -598,6 +648,7 @@ export async function main(argv: string[]): Promise { unlockedUpgrades, args.timeLimitS, args.seed, + args.anomalyId, pool, ); } finally { diff --git a/packages/solver/bin/worker.ts b/packages/solver/bin/worker.ts index 0ca648b..d3cf692 100644 --- a/packages/solver/bin/worker.ts +++ b/packages/solver/bin/worker.ts @@ -13,6 +13,8 @@ import { BUILDINGS } from "../src/data/buildings"; import { solveIsland } from "../src/solver/placementSearch"; import { solve } from "../src/solver/solver"; import type { + AnomalyDefinition, + AnomalyId, EffectiveBuilding, IslandSubGrid, Tile, @@ -25,6 +27,7 @@ type Task = effectiveBuildings: EffectiveBuilding[]; budgetS: number; seed?: number; + anomaly?: AnomalyDefinition; } | { kind: "attempt"; @@ -32,6 +35,7 @@ type Task = unlockedUpgrades: Record; timeLimitS: number; seed?: number; + anomalyId?: AnomalyId; }; parentPort?.on( @@ -46,13 +50,14 @@ parentPort?.on( payload.budgetS, undefined, payload.seed, + payload.anomaly, ) : await solve( payload.grid, [...BUILDINGS], payload.unlockedUpgrades, payload.timeLimitS, - undefined, + { anomalyId: payload.anomalyId }, payload.seed, ); parentPort?.postMessage({ meta, result }); diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index c627a7b..ac23cbf 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -386,12 +386,55 @@ function bestHeatForEngineTiles( return best; } -/** Sum of theoretical max power across all valid island sub-grids. */ +/** + * The largest multiplier any building on this island can be rated at. + * + * One number for the island rather than one per tile, because it is used to + * keep the bound a bound: the estimate below is homogeneous in the roster's + * figures — scale every one of them by `k` and the bound scales by `k` — so + * rating the whole island at its best tile is an upper bound on rating each + * tile at its own. + * + * It is loose by construction where only part of an island qualifies, and that + * is the right trade: a bound that can be beaten is worthless, while one that + * is generous only makes the efficiency figure read low. + */ +function islandMaxScale( + island: IslandSubGrid, + anomaly?: AnomalyDefinition, +): number { + if (anomaly?.rule !== "terrain_affinity") return 1; + if (anomaly.multiplier <= 1) return 1; + // Only water can lie off the board, so the mask answers the whole question + // for the one terrain any shipped anomaly names; anything else is inside the + // window and would need the grid. Erring high keeps this a bound. + if (!anomaly.terrain.includes("water")) return anomaly.multiplier; + + for (let i = 0; i < island.buildable.length; i++) { + if (island.buildable[i] === 1 && island.waterAdjacent[i] === 1) + return anomaly.multiplier; + } + return 1; +} + +/** + * Sum of theoretical max power across all valid island sub-grids. + * + * `anomaly` is taken because the bound has to hold under the rules the search + * is actually running: a terrain bonus rates some tiles above their authored + * figures, and a bound computed on the plain roster is one a real layout beats. + * The "layout efficiency" figure this feeds then reads above 100%, which is the + * visible half of the problem; the invisible half is that nothing else in the + * solver is entitled to assume the bound holds either. + */ export function estimateTotalMaxPower( islands: IslandSubGrid[], roster: EffectiveBuilding[], + anomaly?: AnomalyDefinition, ): number { let total = 0; - for (const island of islands) total += estimateIslandMaxPower(island, roster); + for (const island of islands) + total += + estimateIslandMaxPower(island, roster) * islandMaxScale(island, anomaly); return total; } diff --git a/packages/solver/src/solver/solver.ts b/packages/solver/src/solver/solver.ts index e824995..04498cf 100755 --- a/packages/solver/src/solver/solver.ts +++ b/packages/solver/src/solver/solver.ts @@ -114,7 +114,11 @@ export function planSolve( ), originalWidth: grid[0].length, totalGrassTiles, - theoreticalMaxPower: estimateTotalMaxPower(islands, effectiveBuildings), + theoreticalMaxPower: estimateTotalMaxPower( + islands, + effectiveBuildings, + anomaly, + ), }; } diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index 6c3218f..a634229 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -427,6 +427,70 @@ describe("the theoretical max-power bound", () => { } }); + it("is never beaten by a random layout under a terrain bonus", () => { + /* + * The same property under the one anomaly the search acts on, and the case + * that actually broke it: a shore tile rates its building above the roster, + * so a bound computed on the plain roster is one a real layout walks past. + * A CLI run on Magma Rift reported 119.9% layout efficiency before the + * bound learned about the anomaly. + * + * The board is bare grass, so every tile of it is on the board's edge and + * every building is bonused — the worst case for the bound, and the one + * where being loose is no excuse. + */ + const tidal = getAnomaly("tidal_ascendancy"); + const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); + const island = islandFor(["GGG", "GGG", "GGG"], roster); + const bound = estimateTotalMaxPower([island], roster, tidal); + const ctx = buildIslandContext( + island.grid, + island.buildable, + tidal, + island.waterAdjacent, + ); + expect(ctx.uniformRating, "every tile here is shore").toBe(false); + + const rng = new Rng(20260921); + const options: (EffectiveBuilding | null)[] = [...roster, null]; + for (let attempt = 0; attempt < 300; attempt++) { + const placement: Placement = Array.from({ length: ctx.n }, (_, t) => { + const pick = rng.choice(options); + return pick === null ? null : ctx.rate(t, pick); + }); + + const { totalPower } = simulateIsland(placement, ctx); + + expect( + totalPower, + `a layout beat the 'upper' bound on attempt ${attempt}`, + ).toBeLessThanOrEqual(bound + EPS); + } + }); + + it("rises with a terrain bonus only where a tile qualifies", () => { + const tidal = getAnomaly("tidal_ascendancy"); + const roster = basicRoster(); + // Bare grass: every tile is on the board's edge, so the whole island is + // shore and the bound is the plain one scaled by the multiplier. + const shore = islandFor(["GGG", "GGG", "GGG"], roster); + // Walled in and away from every edge: nothing qualifies, so the bound is + // untouched and stays as tight as it was. + const inland = islandFor( + ["RRRRR", "RGGGR", "RGGGR", "RGGGR", "RRRRR"], + roster, + ); + + expectClose( + estimateTotalMaxPower([shore], roster, tidal), + estimateTotalMaxPower([shore], roster) * 1.67, + ); + expectClose( + estimateTotalMaxPower([inland], roster, tidal), + estimateTotalMaxPower([inland], roster), + ); + }); + it("totals as the sum over islands", () => { const roster = basicRoster(); const islands = splitGridIntoIslands( diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index 671c3d9..7cc90cf 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -350,7 +350,11 @@ class SolverState { hasCoolingSupport, configState.activeAnomaly, ); - return estimateTotalMaxPower(subGrids, effectiveBuildings); + return estimateTotalMaxPower( + subGrids, + effectiveBuildings, + configState.activeAnomaly, + ); } /** How many layouts this solve offers. Below two, there is nothing to cycle. */ From b379f75a59497a86fc521901b5ecf4db8af24b89 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:33:43 +0300 Subject: [PATCH 07/21] Rate the placement writes an earlier pass missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `put()` was meant to be the only way a building enters a placement, but the pass that introduced it matched tile expressions with a character class that stops at the first `]` — so the four writes indexed by `best.neighbors[i]`, `shared.reactorTiles[i]` and the like were left as plain assignments. The seed therefore rated a hub's generator and not its reactors or coolers. The effect is on search quality rather than on output: `downgradeOversized` rebuilds the finished layout from the plain roster through `put`, so what leaves the solver is rated either way. Across three seeds on Magma Rift at 15s under Tidal the fix is worth about a point (mean 180.3 to 181.3), which is inside the noise — it is committed because every write being rated is the invariant `put` exists to provide, not because the measurement demands it. A test pins the agreement that matters instead: the placements a solve reports, rebuilt and re-rated the way the app rebuilds them, score the power the solve reported. Under a terrain bonus a gap there is 67% on every shore tile. Tile-aware seeding was tried and is not here. Ranking a candidate hub by its fit times the tile's multiplier, and restricting a hub's tiles to one class so it cannot straddle the coast, both came back at or below the unchanged search over three seeds. `powerPerTile` orders a greedy claim that later stages rewrite, so skewing it toward the coast mostly moves which tiles are claimed first. Both docs record the negative result so it is not re-attempted blind. --- CLAUDE.md | 10 +++- docs/SOLVER.md | 7 ++- packages/solver/src/solver/placementSearch.ts | 8 ++-- packages/solver/tests/anomalies.test.ts | 46 +++++++++++++++++++ 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 139f6f2..809b88b 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -305,8 +305,14 @@ timeline. `docs/game-logic.md` is the authority on what each one does and *lower* re-rated under Tidal. The search has to keep a cluster on one side of the coast, which is a harder problem than the uniform one. On Magma Rift at 20s, `--anomaly tidal_ascendancy` returns 181AC against the baseline's 142AC. - The seeding heuristics still pick candidates by unscaled roster figures, which - is the remaining gap. + The seeding heuristics pick candidates by unscaled roster figures. Two ways of + making them tile-aware were measured and **neither paid for itself**: ranking + a candidate hub by its fit times the tile's multiplier, and restricting a + hub's tiles to one class so it cannot straddle the coast. Both came back at or + below the unchanged search over three seeds. `powerPerTile` orders a greedy + claim that later stages rewrite, so skewing it toward the coast mostly moves + which tiles get claimed first. Don't re-attempt either without a wider + measurement than three seeds at 15s. **`estimateTotalMaxPower` takes the anomaly, and has to.** A terrain bonus rates some tiles above the roster, so a bound computed on the plain roster is diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 6e50228..133529d 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -134,7 +134,12 @@ waste while an inland cooler still covers x1, so a cluster straddling the coast layout optimised under the base rules scores *lower* re-rated under Tidal, and the search has to keep each cluster on one side of the shoreline. On Magma Rift at 20s, `npm run solve -- --map 3 --anomaly tidal_ascendancy` returns 181AC against the baseline's 142AC. -The seeding heuristics still pick candidates by unscaled roster figures, which is the remaining gap. +The seeding heuristics pick candidates by unscaled roster figures, and two ways of changing that +were measured: ranking a hub by its fit times the tile's multiplier, and restricting a hub's tiles +to one class so it cannot straddle the coast. **Neither paid for itself** — both landed at or below +the unchanged search across three seeds, because `powerPerTile` orders a greedy claim that the +later stages rewrite, so skewing it toward the coast mostly changes which tiles are claimed first. +Don't re-attempt either without a wider measurement. **The bound takes the anomaly too.** `estimateTotalMaxPower` rates each island at its best tile (`islandMaxScale`), because a bound computed on the plain roster is one a bonused layout walks diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index e2cc405..8960525 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -660,17 +660,17 @@ function constructSeed( put(placement, ctx, best.tile, generator); claimed.push(best.tile); for (let i = 0; i < r; i++) { - placement[best.neighbors[i]] = reactor; + put(placement, ctx, best.neighbors[i], reactor); claimed.push(best.neighbors[i]); } for (let i = r; i < r + c; i++) { - placement[best.neighbors[i]] = topCooler; + 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++) { - placement[shared.reactorTiles[i]] = shared.reactorTiers[i]; + put(placement, ctx, shared.reactorTiles[i], shared.reactorTiers[i]); claimed.push(shared.reactorTiles[i]); } for (const n of shared.genPositions) { @@ -686,7 +686,7 @@ function constructSeed( put(placement, ctx, best.tile, dp); claimed.push(best.tile); for (let i = 0; i < c; i++) { - placement[best.neighbors[i]] = topCooler; + put(placement, ctx, best.neighbors[i], topCooler); claimed.push(best.neighbors[i]); } } diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts index 7f0d0fb..0046608 100644 --- a/packages/solver/tests/anomalies.test.ts +++ b/packages/solver/tests/anomalies.test.ts @@ -21,6 +21,8 @@ import { wasteIsCovered } from "../src/solver/physics"; import { buildIslandContext } from "../src/solver/context"; import { simulateIsland } from "../src/solver/simulate"; import { makeGrid } from "../src/grid"; +import { splitGridIntoIslands } from "../src/solver/island"; +import { solveIsland } from "../src/solver/placementSearch"; import { basicRoster, cooler } from "./helpers"; import type { EffectiveBuilding, Placement } from "../src/solver/types"; @@ -180,6 +182,50 @@ describe("a terrain bonus reaching the board", () => { } }); + it("reports a power the same layout re-rates to", async () => { + /* + * The agreement between the solver and everything that reads its output. + * A solve returns placements naming a building and an authored tier; the + * app rebuilds them with `effectiveAtValue` and rates each for its tile, so + * if that round trip does not land on the figure the solver reported, the + * board and the panel describing it disagree — and under a terrain bonus + * the gap is 67% on every shore tile rather than a rounding. + */ + const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); + const island = splitGridIntoIslands( + makeGrid(["GGGGGG", "GGGGGG", "GGGGGG"]), + true, + tidal, + )[0]; + const ctx = buildIslandContext( + island.grid, + island.buildable, + tidal, + island.waterAdjacent, + ); + const byId = new Map(roster.map((b) => [b.id, b])); + + const solution = await solveIsland(island, roster, 0.5, undefined, 11, tidal); + expect(solution.placements.length).toBeGreaterThan(0); + + // Tile index by the island-local coordinates the report carries. + const tileAt = new Map(); + for (let t = 0; t < ctx.n; t++) tileAt.set(`${ctx.xs[t]},${ctx.ys[t]}`, t); + + // Rebuild the reported layout the way the app does — every building rated + // for the tile it sits on — and score it. + const rebuilt: Placement = new Array(ctx.n).fill(null); + for (const p of solution.placements) { + const tile = tileAt.get(`${p.x},${p.y}`)!; + rebuilt[tile] = ctx.rate(tile, byId.get(p.buildingId)!); + } + + expect(simulateIsland(rebuilt, ctx).totalPower).toBeCloseTo( + solution.powerOutput, + 6, + ); + }); + it("scores a shore layout above the same layout inland", () => { /* * The rule end to end. The same three buildings, the same arrangement, on From f91beee61c97cf9cf681f0c1189c85a1ab125b28 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:45:30 +0300 Subject: [PATCH 08/21] Implement Singularity Isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second anomaly the search acts on, and the first whose multiplier depends on the layout rather than the board. A generator's rating is a function of what its neighbours are, so placing one re-rates up to eight other tiles: it cannot be folded into a tile the way a terrain bonus can, and it cannot be settled where a building is written. It is resolved per layout in `simulateIsland`, the one place a whole layout is in hand. The placement is copied into `ctx.ratedLayout` — held by the island rather than allocated per call, the same argument every other buffer there is held for — the affected role's tiles are re-rated, and every figure is read through that. The neighbour scan reads the original placement on purpose: the test is on what a neighbour *is*, which no rating changes, so there is no order to get right. `rateIsolated` is a lookup over two variants per roster entry, for the reason `rate` is a lookup — the scaled waste is a snapped difference and the snap is a string round-trip. Modelled by role rather than by the catalogue's grouping, so a wind turbine neither takes the penalty nor imposes it, and coolers and reactors are rated exactly as authored however tightly they are packed. One neighbour costs what five do. Worth recording what the rule turns out to be worth: close to nothing. x2.5 scales a generator's *intake*, which is a capacity — fed by the reactors it already had, a bonused generator fills to 40% and produces exactly what it did before. Only the penalty bites, and it is avoidable by keeping generators apart. Map 3 at 15s: 1.4075e23 under the anomaly against a 1.4202e23 baseline, with the baseline layout re-rated at 1.2849e23 — the search recovers most of the penalty and finds no gain. Map 1 is identical to three significant figures either way. Sizing the composition retarget from the isolated rating was tried, since it is the only stage that can propose the different mix the bonus would need, and changed nothing across three seeds. It is not in the tree. No fixture moves. --- CLAUDE.md | 31 ++++- docs/SOLVER.md | 19 ++- packages/solver/src/solver/context.ts | 104 +++++++++++++--- packages/solver/src/solver/simulate.ts | 68 +++++++++-- packages/solver/tests/anomalies.test.ts | 152 ++++++++++++++++++++++++ 5 files changed, 343 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 809b88b..7ee224b 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,13 +272,34 @@ timeline. `docs/game-logic.md` is the authority on what each one does and changes which tier a *placement* resolves to (`rebasePlacements`), while an anomaly changes none of them — the same building at the same tier is simply rated differently — so it needs the re-score alone. -- **Tidal Ascendancy is implemented; Cryo Nexus and Singularity Isolation are +- **Tidal Ascendancy and Singularity Isolation are implemented; Cryo Nexus is not.** The anomaly is threaded the whole way — `SolveOptions.anomalyId` / `SolveRunOptions.anomalyId`, the worker request, and `IslandContext.anomaly` — - and the terrain half of it is resolved into `IslandContext.rate`. The two - remaining rules need more than a per-tile multiplier: Singularity's depends on - a building's neighbours, so it changes as the search moves them, and Cryo's is - the board-level problem above. Nothing on screen says a solve ignored one. + and each rule is resolved wherever its inputs are settled. Cryo is the + board-level problem above and nothing on screen says a solve ignored it. + + **Singularity is resolved per layout, in `simulateIsland`, and it has to be.** + A generator's rating is a function of what its neighbours *are*, so placing + one re-rates up to eight other tiles — it cannot be folded into a tile like a + terrain bonus, and it cannot be settled at the write. `simulateIsland` copies + the layout into `ctx.ratedLayout` (held by the island, not allocated per + call), re-rates the affected role's tiles, and reads its figures through that. + 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 bonus is on a capacity, which is why the rule is close to power-neutral + here.** x2.5 scales a generator's *intake*, and a generator fed by the + reactors it already had simply fills to 40% and produces exactly what it did + before; the bonus is worth something only alongside more adjacent reactor + heat. The penalty is real and avoidable, so the search's job under this + anomaly is mostly to keep generators apart. Map 3 at 15s: 1.4202e23 baseline, + 1.4075e23 under Singularity, against 1.2849e23 for the *baseline layout* + re-rated — so the search recovers most of the penalty and finds no gain. + Map 1 comes back identical to three significant figures either way. + + Sizing the composition retarget from the isolated rating — the one stage that + can propose a different mix, and the mix is what the bonus would need — was + measured and changed nothing on three seeds. It is not in the tree. **A terrain bonus is resolved per tile, once, when the context is built.** Nothing about a layout can change which tiles qualify, so `terrainScales` diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 133529d..945dd37 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -107,7 +107,7 @@ Two things about the shapes are load-bearing: - **`role_isolation` depends on the layout, the other shapes do not.** A terrain bonus is fixed per tile and can be folded into the board; a generator's isolation multiplier changes every time the search moves a neighbour, so a placed building's figures have to be resolved per - layout. + layout — which `simulateIsland` does, below. `terrain_affinity` is the one rule implemented. `terrainScales` resolves it into a per-tile multiplier when the context is built — nothing about a layout can change which tiles qualify — and @@ -141,6 +141,23 @@ the unchanged search across three seeds, because `powerPerTile` orders a greedy later stages rewrite, so skewing it toward the coast mostly changes which tiles are claimed first. Don't re-attempt either without a wider measurement. +`role_isolation` is resolved in `simulateIsland`, the one place a whole layout is in hand. It +copies the placement into `ctx.ratedLayout` — held by the island, not allocated per call — re-rates +the affected role's tiles and reads every figure through that. Two details are load-bearing: 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; and `ctx.rateIsolated` is a lookup over two +variants per roster entry rather than a multiply, for the same snap-is-a-string-round-trip reason +`rate` is. + +**This anomaly is close to power-neutral on the shipped maps, and that is the rule rather than the +search.** x2.5 scales a generator's *intake*, which is a capacity: fed by the reactors it already +had, a bonused generator fills to 40% and produces exactly what it did before. Only the penalty +bites, and the penalty is avoidable by keeping generators apart. Map 3 at 15s comes back 1.4075e23 +against a 1.4202e23 baseline, where the baseline layout *re-rated* under the anomaly is 1.2849e23 — +so the search recovers most of the penalty and there is no gain to find. Sizing the composition +retarget from the isolated rating, which is the only stage that can propose the different mix the +bonus would need, was measured and changed nothing. + **The bound takes the anomaly too.** `estimateTotalMaxPower` rates each island at its best tile (`islandMaxScale`), because a bound computed on the plain roster is one a bonused layout walks past: Magma Rift reported 119.9% layout efficiency before it did, and nothing else in the solver is diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index a51c8d0..f2cdf63 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -4,6 +4,7 @@ import { CHEBYSHEV_DIRECTIONS, spatialKeyCompare } from "./constants"; import { computeWaterAdjacency } from "./island"; import type { AnomalyDefinition, + BuildingType, EffectiveBuilding, Tile, TileType, @@ -147,6 +148,35 @@ export interface IslandContext { * once and then handed back. */ rate(tile: number, building: EffectiveBuilding): EffectiveBuilding; + /** + * The role-isolation rule in force, or `null` under every other anomaly. + * + * This one cannot be folded into a tile the way a terrain bonus can: a + * building's rating is a function of *what its neighbours are*, so placing + * one re-rates up to eight other tiles and the answer changes with every move + * the search makes. It is resolved per layout instead, by `simulateIsland`, + * which is the one place a whole layout is in hand. + */ + readonly isolation: RoleIsolation | null; + /** + * `building` rated for a tile that does, or does not, touch another building + * of the isolated role. + * + * A lookup rather than a multiply, for the reason `rate` is: the scaled + * waste is a snapped difference and the snap is a string round-trip. There + * are only two variants of each roster entry, so they are built once per + * island and handed back for the rest of the solve. + */ + rateIsolated(building: EffectiveBuilding, crowded: boolean): EffectiveBuilding; + /** + * A whole-layout scratch buffer, reused by `simulateIsland` when a rule has + * to resolve a layout before scoring it. + * + * One array per island rather than one per call: the search runs millions of + * simulations, and this is the same argument every other buffer here is held + * for. + */ + readonly ratedLayout: (EffectiveBuilding | null)[]; readonly xs: Int32Array; readonly ys: Int32Array; /** Chebyshev-adjacent buildable tiles of each tile, ascending index order. */ @@ -168,6 +198,13 @@ export interface IslandContext { readonly wasteTiles: Int32Array; } +/** The resolved `role_isolation` rule: which role, and its two multipliers. */ +export interface RoleIsolation { + readonly role: BuildingType; + readonly isolated: number; + readonly crowded: number; +} + /** * The per-tile stat multiplier a terrain bonus produces, or `null` when every * tile on this island rates the same. @@ -316,6 +353,14 @@ export function buildIslandContext( // once per island rather than once per placement. Keyed on the base object // because the roster is shared by every tile and its entries are stable for // the whole solve. + const isolation: RoleIsolation | null = + anomaly.rule === "role_isolation" + ? { + role: anomaly.role, + isolated: anomaly.isolated, + crowded: anomaly.crowded, + } + : null; const rated = new Map>(); // Every scaled variant back to the roster entry it came from, so `rate` can // be applied to a building that already carries a rating. The search swaps @@ -324,31 +369,58 @@ export function buildIslandContext( // and the layout would quietly be worth 2.8x. const baseOf = new Map(); + /** + * `building` at `scale`, built once per distinct pair. + * + * Idempotent: a building arriving with a rating already on it is taken back + * to its roster entry first, so this is a function of the scale rather than + * of how many times it has been applied. The search swaps buildings between + * tiles and restores them when a move is rejected, so it hands back objects + * it was given — without this, a restore would scale a scaled building. + */ + const scaledBy = ( + building: EffectiveBuilding, + scale: number, + ): EffectiveBuilding => { + const base = baseOf.get(building) ?? building; + if (scale === 1) return base; + + let byBuilding = rated.get(scale); + if (byBuilding === undefined) { + byBuilding = new Map(); + rated.set(scale, byBuilding); + } + let scaled = byBuilding.get(base); + if (scaled === undefined) { + scaled = scaleEffectiveBuilding(base, scale); + byBuilding.set(base, scaled); + baseOf.set(scaled, base); + } + return scaled; + }; + return { n, anomaly, uniformRating, + isolation, + ratedLayout: new Array(n).fill(null), + rateIsolated( + building: EffectiveBuilding, + crowded: boolean, + ): EffectiveBuilding { + if (isolation === null) return building; + return scaledBy( + building, + crowded ? isolation.crowded : isolation.isolated, + ); + }, rate(tile: number, building: EffectiveBuilding): EffectiveBuilding { if (tileScale === null) return building; // Idempotent: a building arriving with another tile's rating is taken // back to its roster entry first, so this is a function of the tile // rather than of how many times it has been applied. - const base = baseOf.get(building) ?? building; - const scale = tileScale[tile]; - if (scale === 1) return base; - - let byBuilding = rated.get(scale); - if (byBuilding === undefined) { - byBuilding = new Map(); - rated.set(scale, byBuilding); - } - let scaled = byBuilding.get(base); - if (scaled === undefined) { - scaled = scaleEffectiveBuilding(base, scale); - byBuilding.set(base, scaled); - baseOf.set(scaled, base); - } - return scaled; + return scaledBy(building, tileScale[tile]); }, xs, ys, diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts index 828e181..d285857 100755 --- a/packages/solver/src/solver/simulate.ts +++ b/packages/solver/src/solver/simulate.ts @@ -91,6 +91,56 @@ export function simulateIsland( }; } + // A role-isolation rule rates a building by what its neighbours *are*, so it + // cannot be folded into a tile and cannot be resolved at the moment a + // building is placed — one write re-rates up to eight other tiles. It is + // resolved here instead, where a whole layout is in hand, into a buffer held + // by the island rather than allocated per call. + // + // The scan is over the affected role's tiles only, and reads `placement` + // 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 + : isolation.role === "cooler" + ? coolerTiles + : isolation.role === "reactor" + ? reactorTiles + : dpTiles; + const count = + isolation.role === "generator" + ? numGenerators + : isolation.role === "cooler" + ? numCoolers + : isolation.role === "reactor" + ? numReactors + : numDps; + + for (let i = 0; i < count; i++) { + const t = affected[i]; + const neighbors = ctx.neighbors[t]; + let crowded = false; + for (let k = 0; k < neighbors.length; k++) { + const other = placement[neighbors[k]]; + if (other !== null && other.type === isolation.role) { + // One neighbour costs exactly what five do — the rule is a two-way + // test, not a count. + crowded = true; + break; + } + } + rated[t] = ctx.rateIsolated(placement[t]!, crowded); + } + layout = rated; + } + const d = ctx.dist; const heatIn = ctx.heatIn; const heatOut = ctx.heatOut; @@ -104,10 +154,10 @@ export function simulateIsland( if (numReactors > 0 && numGenerators > 0) { for (let i = 0; i < numReactors; i++) { - d.supplierCap[i] = placement[reactorTiles[i]]!.effectiveValue; + d.supplierCap[i] = layout[reactorTiles[i]]!.effectiveValue; } for (let j = 0; j < numGenerators; j++) { - d.consumerCap[j] = placement[generatorTiles[j]]!.effectiveValue; + d.consumerCap[j] = layout[generatorTiles[j]]!.effectiveValue; } runDistribution( reactorTiles, @@ -126,7 +176,7 @@ export function simulateIsland( // the conversion is per tier and is not a fixed 75/25. See `physics.ts`. for (let j = 0; j < numGenerators; j++) { const t = generatorTiles[j]; - generatorPowerAndWaste(placement[t]!, heatIn[t], conversion); + generatorPowerAndWaste(layout[t]!, heatIn[t], conversion); powerOf[t] = conversion.power; wasteOf[t] = conversion.waste; } @@ -135,7 +185,7 @@ export function simulateIsland( // unscaled. for (let k = 0; k < numDps; k++) { const t = dpTiles[k]; - const b = placement[t]!; + const b = layout[t]!; powerOf[t] = b.energy; wasteOf[t] = b.waste; } @@ -161,7 +211,7 @@ export function simulateIsland( if (numWaste > 0) { for (let i = 0; i < numCoolers; i++) { - d.supplierCap[i] = placement[coolerTiles[i]]!.effectiveValue; + d.supplierCap[i] = layout[coolerTiles[i]]!.effectiveValue; } for (let j = 0; j < numWaste; j++) { d.consumerCap[j] = wasteOf[wasteTiles[j]]; @@ -179,13 +229,13 @@ export function simulateIsland( for (let i = 0; i < numReactors; i++) { const t = reactorTiles[i]; - const b = placement[t]!; + const b = layout[t]!; placements[out++] = row(t, ctx, b, 0, heatOut[t], 0, 0, 0, 0); } for (let j = 0; j < numGenerators; j++) { const t = generatorTiles[j]; - const b = placement[t]!; + const b = layout[t]!; const waste = wasteOf[t]; const cooling = coolingIn[t]; // All-or-nothing: a producer whose waste outruns the cooling routed to it @@ -197,7 +247,7 @@ export function simulateIsland( for (let k = 0; k < numDps; k++) { const t = dpTiles[k]; - const b = placement[t]!; + const b = layout[t]!; const waste = wasteOf[t]; const cooling = coolingIn[t]; const power = wasteIsCovered(waste, cooling) ? powerOf[t] : 0.0; @@ -207,7 +257,7 @@ export function simulateIsland( for (let i = 0; i < numCoolers; i++) { const t = coolerTiles[i]; - const b = placement[t]!; + const b = layout[t]!; placements[out++] = row(t, ctx, b, 0, 0, 0, 0, heatOut[t], 0); } diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts index 0046608..95b3597 100644 --- a/packages/solver/tests/anomalies.test.ts +++ b/packages/solver/tests/anomalies.test.ts @@ -109,6 +109,158 @@ describe("scaling a resolved building", () => { }); }); +describe("role isolation reaching the board", () => { + /* + * The one rule whose multiplier depends on the layout rather than on the + * board, so it is resolved per simulation instead of per tile. The test is on + * what a neighbour *is*, never on what it is doing — an idle, booting or + * overheated generator costs its neighbour the penalty just the same. + * + * The roster gives the reactor far more heat than the generator can take, so + * the generator is never starved: with a reactor only as large as the + * generator's authored intake, a x2.5 intake bonus buys nothing at all and + * every case below would read as 1.0. + */ + const singularity = getAnomaly("singularity_isolation"); + const roster = basicRoster({ + reactorValue: 500, + coolerValue: 200, + dpValue: 120, + dpWasteRatio: 0.2, + }); + const CODES: Record = { + r: roster[0], + g: roster[1], + c: roster[2], + d: roster[3], + }; + + /** A 7x5 board of grass walled in by rock, so no tile of it is on an edge. */ + const BOARD = [ + "RRRRRRR", + "RGGGGGR", + "RGGGGGR", + "RGGGGGR", + "RRRRRRR", + ]; + + /** + * Scores `spec` — `[x, y, code]` in board coordinates — and returns each + * building's own power, keyed "x,y". Run twice per case, under the anomaly + * and under none, so what is compared is one layout against itself. + */ + const powers = (spec: [number, number, string][], anomaly = singularity) => { + const ctx = buildIslandContext(makeGrid(BOARD), undefined, anomaly); + const at = new Map(); + for (let t = 0; t < ctx.n; t++) at.set(`${ctx.xs[t]},${ctx.ys[t]}`, t); + + const placement: Placement = new Array(ctx.n).fill(null); + for (const [x, y, code] of spec) placement[at.get(`${x},${y}`)!] = CODES[code]; + + const out = new Map(); + for (const row of simulateIsland(placement, ctx, true).placements) + out.set(`${row.x},${row.y}`, row.powerGenerated); + return out; + }; + + it("rates a generator with no generator beside it at the bonus", () => { + const spec: [number, number, string][] = [ + [1, 1, "r"], + [2, 1, "g"], + [3, 1, "c"], + ]; + + const base = powers(spec, getAnomaly("none")).get("2,1")!; + expect(base).toBeGreaterThan(0); + expect(powers(spec).get("2,1")).toBeCloseTo(base * 2.5, 6); + }); + + it("penalises both generators the moment they touch", () => { + // Two generators side by side, each with its own reactor and cooler. The + // rule is mutual and two-way, so both drop together. + const spec: [number, number, string][] = [ + [1, 1, "r"], + [2, 1, "g"], + [3, 1, "g"], + [4, 1, "r"], + [2, 2, "c"], + [3, 2, "c"], + ]; + + const base = powers(spec, getAnomaly("none")); + const crowded = powers(spec); + + expect(base.get("2,1")).toBeGreaterThan(0); + expect(crowded.get("2,1")).toBeCloseTo(base.get("2,1")! * 0.8, 6); + expect(crowded.get("3,1")).toBeCloseTo(base.get("3,1")! * 0.8, 6); + }); + + it("costs no more for a second neighbour than for the first", () => { + // Three generators in a row, each with a reactor above it and a cooler + // below. The middle one touches two generators and the outer ones touch + // one; all three take exactly x0.8 — the rule is a test, not a count. + const spec: [number, number, string][] = [ + [2, 1, "r"], + [3, 1, "r"], + [4, 1, "r"], + [2, 2, "g"], + [3, 2, "g"], + [4, 2, "g"], + [2, 3, "c"], + [3, 3, "c"], + [4, 3, "c"], + ]; + + const base = powers(spec, getAnomaly("none")); + const crowded = powers(spec); + + for (const key of ["2,2", "3,2", "4,2"]) { + expect(base.get(key)).toBeGreaterThan(0); + expect(crowded.get(key), key).toBeCloseTo(base.get(key)! * 0.8, 6); + } + }); + + it("does not let a direct producer trigger the penalty", () => { + /* + * A wind turbine is a power source and not a Generator, so a generator + * beside one keeps its bonus — and takes none itself. Modelled by the roles + * the buildings are rather than by the catalogue's grouping, which files + * turbines with the reactors. + */ + const spec: [number, number, string][] = [ + [1, 1, "r"], + [2, 1, "g"], + [3, 1, "d"], + [2, 2, "c"], + [3, 2, "c"], + ]; + + const base = powers(spec, getAnomaly("none")); + const under = powers(spec); + + expect(under.get("2,1")).toBeCloseTo(base.get("2,1")! * 2.5, 6); + // And the turbine beside it is rated exactly as authored. + expect(under.get("3,1")).toBeCloseTo(base.get("3,1")!, 6); + }); + + it("leaves coolers and reactors alone however tightly they are packed", () => { + const spec: [number, number, string][] = [ + [1, 1, "d"], + [2, 1, "d"], + [1, 2, "c"], + [2, 2, "c"], + ]; + + const base = powers(spec, getAnomaly("none")); + const under = powers(spec); + + for (const key of ["1,1", "2,1"]) { + expect(base.get(key)).toBeGreaterThan(0); + expect(under.get(key), key).toBeCloseTo(base.get(key)!, 6); + } + }); +}); + describe("a terrain bonus reaching the board", () => { /* * Tidal Ascendancy is the only rule in the game where an unbuildable tile From 858372006a339e548b83cc496492bfbfc1c82343 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:02:07 +0300 Subject: [PATCH 09/21] Implement Cryo Nexus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the three, and the only one that reaches past a building's own figures. The game's "island" is the map, so under a shared pool the components `splitGridIntoIslands` produces are not independent — which is the assumption the worker pool, the budget split, `IslandBest` and `variants.ts` all rest on. The resolution is to stop producing them. `wholeBoardIsland` hands the board over as a single island, which makes the pool board-wide by construction and leaves every one of those assumptions true. It costs nothing elsewhere: `runDistribution` already scopes its round budget and its repair to each connected component of the supplier/consumer graph, a finer partition than the island, so heat stays adjacency-bound. It is also what brings in the ground no decomposition keeps — every grass tile is on the one island, the 21 lone tiles across the shipped maps included, so `minIslandTiles` loses its Cryo case rather than gaining one. The price is per-island parallelism: one island is one pool task. Small on the shipped maps, where one landmass already holds ~95% of the budget, and it buys the whole board-level problem for nothing — the alternative is describing each component by a frontier of power against net cooling contributed and combining those under one scalar budget. The pool itself needs no second code path. `simulateIsland` replaces the cooling distribution with the game's DistributeCryoArea — total the coolers, total what the sources are owed, serve everyone the same fraction — and because the fraction is common, a short pool leaves every source under its waste at once. So the ordinary per-producer online test turns it into the board-wide all-or-nothing the rule describes, with no board-level flag anywhere. The x0.88 is not part of the pool. It is a uniform scale on the cooler role applied through `ctx.rate`, because the game applies it to CoolerBuilding.CoolingPerSec: it is what a cooler is worth and what the game shows for it. `contribution` is renamed `coolerMultiplier` to say so, matching the game's own field, and `rate` gains a per-role factor beside its per-tile one — only ever one of the two is in force, since only one anomaly runs at a time. Worth real power, and most where the board is fragmented, since the x0.88 has to be earned back first: map 7 at 25s goes 271AC to 294AC, map 3 at 20s 139AC to 145AC, and map 1 — one landmass, already at 98% of its bound — is unchanged. No fixture moves. --- CLAUDE.md | 52 +++++++--- docs/SOLVER.md | 65 ++++++------ docs/game-logic.md | 2 +- packages/solver/src/data/anomalies.ts | 2 +- packages/solver/src/solver/context.ts | 22 +++- packages/solver/src/solver/island.ts | 88 +++++++++++++--- packages/solver/src/solver/simulate.ts | 66 ++++++++++-- packages/solver/src/solver/types.ts | 16 +-- packages/solver/tests/anomalies.test.ts | 127 ++++++++++++++++++++++++ packages/solver/tests/island.test.ts | 52 +++++----- 10 files changed, 384 insertions(+), 108 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ee224b..4c59cca 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,22 +226,42 @@ timeline. `docs/game-logic.md` is the authority on what each one does and than in `ui_prefs` because it is a **solve input** like the roster, not a preference about the app — which is also why `solveSignature()` counts it and a solve found under another anomaly restores as stale. -- **Cryo Nexus pools across the whole board.** The game's "island" is the map, - so under it the islands `splitGridIntoIslands` produces **do** interact — - against the assumption the worker pool, the budget split, `IslandBest` and - `variants.ts` are built on. Heat stays adjacency-bound, so an island is still - solvable alone; it is just no longer described by its best power but by a - frontier of power against net cooling contributed, with the board combining - those under one scalar budget. See `docs/SOLVER.md`. - - It also makes **`minIslandTiles` 1**. The 2-and-3-tile floors hold only - because cooling has to cross a tile boundary; a lone tile under Cryo takes a - heat sink that pays into the pool, or a direct producer the pool pays for. - That is 21 tiles across the shipped maps which no other rule in the game can - use. `splitGridIntoIslands` therefore takes the anomaly — the floor is a - property of the rules, so it cannot be applied after the split — and every - caller passes the same one the run is planned under, the app's two estimates - included, or the bound and the clock describe a different board from the run. +- **Cryo Nexus pools across the whole board, so under it the board is one + island.** The game's "island" is the map, so the components + `splitGridIntoIslands` produces **do** interact — against the assumption the + worker pool, the budget split, `IslandBest` and `variants.ts` all rest on. The + answer is to stop producing them: `wholeBoardIsland` hands the board over + entire, which makes the pool board-wide by construction and leaves every one + of those assumptions true. + + It costs nothing in the rest of the simulation, because `runDistribution` + already scopes its round budget and its repair to each connected component of + the supplier/consumer graph — a finer partition than the island — so heat + stays adjacency-bound. It is also what brings the unbuildable scraps in: every + grass tile is on the one island, including the 21 tiles across the shipped + maps that no decomposition keeps. `minIslandTiles` therefore has no Cryo case; + there is nothing to apply a floor to. + + **The price is per-island parallelism** — one island is one pool task, so a + board of two large landmasses searches on one core where it could use two. A + small loss on the shipped maps, where one landmass already holds ~95% of the + budget, and it buys the whole board-level problem for nothing: the alternative + is describing each component by a frontier of power against net cooling + contributed and combining those under one scalar budget. + + **The pool itself is in `simulateIsland` and needs no second code path.** It + serves every source the same fraction of what it is owed, so a short pool + leaves *all* of them under their waste and the ordinary per-producer online + test turns that into the board-wide all-or-nothing the rule describes. The + x0.88 is a uniform scale on the cooler role, applied through `ctx.rate` like + any other multiplier, because the game applies it to `CoolerBuilding. + CoolingPerSec` — it is what a cooler is worth and what the game shows for it, + not a charge levied at the pool. + + It is worth real power, and most on a fragmented board: map 7 at 25s goes + 271AC to 294AC, map 3 at 20s 139AC to 145AC, and map 1 — one landmass, already + at 98% of its bound — is unchanged. The x0.88 has to be earned back before any + of that shows. - **Off the board counts as water**, which is a fact about our data rather than about the game: the game has one global map with open water between islands, and our eight boards are rectangles cut out of it, so the water past an edge is diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 945dd37..4c661a7 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -164,36 +164,45 @@ past: Magma Rift reported 119.9% layout efficiency before it did, and nothing el entitled to assume the bound holds either. It is loose where only part of an island qualifies, which is the right way to be wrong. -`shared_cooling` is the odd one out entirely, and it is the one that costs this package -something. **Its pool is the whole board** — the game's "island" is the map, Gale Hills and Ash -Bay, and "cooling does not carry over to other islands" means it does not carry to a different -map. So under Cryo Nexus the islands `splitGridIntoIslands` produces are **not independent**, -which is the assumption the worker pool, the per-island budget split, `IslandBest` and +`shared_cooling` is the odd one out entirely: it is the only shape that changes more than a +building's figures. **Its pool is the whole board** — the game's "island" is the map, Gale Hills +and Ash Bay, and "cooling does not carry over to other islands" means it does not carry to a +different map. So under Cryo Nexus the components `splitGridIntoIslands` produces are **not** +independent, which is the assumption the worker pool, the per-island budget split, `IslandBest` and `variants.ts` all rest on. -What survives is more than it sounds. Heat stays adjacency-bound, so how much power an island can -make is still a question about that island alone; the islands are coupled by exactly two scalars, -the cooling they contribute and the waste they generate, and the board runs only if the first sums -over the second. An island is therefore no longer described by "its best power" but by a frontier — -power against net cooling it puts into or takes out of the pool — and the board-level problem is to -combine those frontiers under one scalar budget. That keeps the per-island parallelism; it changes -what a worker is asked for. - -Two smaller effects. The board is sustainable **together** — every power source is served the same -fraction, so either the pool covers the board's whole waste or no part of it holds — which is what -makes one scalar budget the right shape for the board-level problem. And **`minIslandTiles` returns -1**: the 2-and-3-tile floors hold only because cooling has to cross a tile boundary, and under Cryo -it does not. A lone tile takes a heat sink that pays into the pool, or a direct producer the pool -pays for; a lone generator or reactor stays worthless, heat being adjacency-bound either way, and -the search leaves that tile empty. The shipped maps have 21 such tiles between them, 0 to 6 each — -small, but they are ground no other rule in the game can use. They arrive as degenerate one-tile -islands; the budget split is proportional to tile count, so each draws a share to match. - -`docs/game-logic.md` has the rules. Two are worth repeating here because they decide how much of a -board this package has to look at: a pond is an obstacle and grants no shore bonus, while **off the -board counts as water** — the shipped maps are rectangles cut from one global map that is open -water between islands. The Tidal bonus lands on 36-45% of the grass of every shipped map, and on -the whole perimeter of a custom one. +**The resolution is to stop producing them.** `wholeBoardIsland` hands the board over as a single +island, which makes the pool board-wide by construction and leaves every one of those assumptions +true. It costs nothing elsewhere in the simulation: `runDistribution` already scopes its round +budget and its repair to each connected component of the supplier/consumer graph, which is a finer +partition than the island, so heat stays adjacency-bound. It is also what brings in the tiles no +decomposition keeps — every grass tile is on the one island, including the 21 across the shipped +maps that are otherwise dead ground, so `minIslandTiles` has no Cryo case to answer. + +The price is per-island parallelism: one island is one pool task, so a board of two large +landmasses searches on one core where it could have used two. On the shipped maps that is small — +most are one landmass and a few scraps, so one island already holds about 95% of the budget — and +it buys the entire board-level problem for nothing. The alternative is to describe each component +by a frontier of power against the net cooling it contributes and combine those frontiers under one +scalar budget, which is a different and much larger machine. + +**The pool needs no second code path through the rest of the simulation.** `simulateIsland` +replaces the cooling distribution with the game's `DistributeCryoArea`: total the coolers, total +what the sources are owed, and serve everyone the same fraction. Because the fraction is common, a +short pool leaves *every* source under its waste at once — so the ordinary per-producer online test +below it produces the board-wide all-or-nothing the rule describes, with no board-level flag +anywhere. Each cooler is reported its share of what the pool actually absorbed, which is the game's +own reporting rule. + +The x0.88 is not part of that. It is a uniform scale on the cooler role, applied through `ctx.rate` +like any other multiplier, because the game applies it to `CoolerBuilding.CoolingPerSec` — it is +what a cooler is worth and what the game shows for it, not a charge levied at the pool. `rate` +therefore carries a per-role factor beside its per-tile one; only ever one of the two is in force, +since only one anomaly runs at a time. + +It is worth real power, and most on a fragmented board, since the x0.88 has to be earned back +first: map 7 at 25s goes 271AC to 294AC, map 3 at 20s 139AC to 145AC, and map 1 — one landmass, +already at 98% of its bound — is unchanged. ### `island.ts` — the window an island is solved in diff --git a/docs/game-logic.md b/docs/game-logic.md index ec35d11..b2d3f6c 100755 --- a/docs/game-logic.md +++ b/docs/game-logic.md @@ -544,7 +544,7 @@ board splits into independent patches of connected grass and each can be solved on its own. Under this anomaly they are not independent. They are still *nearly* independent, and the shape of what survives is worth -being precise about, because it is the whole basis of solving this efficiently: +being precise about, because it is what decides how hard this is to solve: - **Heat is untouched.** Reactors still only reach adjacent generators, so which buildings a patch can bring online, and how much power it makes, is still a diff --git a/packages/solver/src/data/anomalies.ts b/packages/solver/src/data/anomalies.ts index d5fb3c3..981116c 100644 --- a/packages/solver/src/data/anomalies.ts +++ b/packages/solver/src/data/anomalies.ts @@ -43,7 +43,7 @@ export const ANOMALIES: readonly AnomalyDefinition[] = [ "is. If there is not enough Cooling, every Power Source receives the " + "same percentage of what it needs. Each Heat Sink contributes ×0.88 of " + "its normal Cooling. Cooling does not carry over to other islands.", - contribution: 0.88, + coolerMultiplier: 0.88, }, { id: "tidal_ascendancy", diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index f2cdf63..68941a7 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -348,6 +348,18 @@ export function buildIslandContext( n, waterAdjacent, ); + // A uniform scale on one role, which is what Cryo Nexus does to coolers: the + // game applies its 0.88 to `CoolerBuilding.CoolingPerSec`, so it is what a + // cooler is worth rather than a charge levied at the pool. + // + // Only ever one of these two is in force, because only one anomaly runs at a + // time — so the product below is always a multiply by exactly 1.0 on one + // side, and never the two-factor rounding `scaleEffectiveBuilding` guards + // against. + const roleScale: Partial> | null = + anomaly.rule === "shared_cooling" + ? { cooler: anomaly.coolerMultiplier } + : null; const uniformRating = tileScale === null; // One cache per distinct scale, so a building is built at a given rating // once per island rather than once per placement. Keyed on the base object @@ -416,11 +428,13 @@ export function buildIslandContext( ); }, rate(tile: number, building: EffectiveBuilding): EffectiveBuilding { - if (tileScale === null) return building; + if (tileScale === null && roleScale === null) return building; // Idempotent: a building arriving with another tile's rating is taken - // back to its roster entry first, so this is a function of the tile - // rather than of how many times it has been applied. - return scaledBy(building, tileScale[tile]); + // back to its roster entry first, so this is a function of the tile and + // the role rather than of how many times it has been applied. + const byTile = tileScale === null ? 1 : tileScale[tile]; + const byRole = roleScale?.[building.type] ?? 1; + return scaledBy(building, byTile * byRole); }, xs, ys, diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index ac23cbf..4bb9d16 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -53,22 +53,16 @@ export function canCoolDirectProducer(roster: EffectiveBuilding[]): boolean { /** * The fewest tiles a patch needs before anything on it can be worth building. * - * Both ordinary floors exist for one reason: cooling has to cross a tile - * boundary, so a producer needs a cooler beside it. A reactor + generator + - * cooler chain is 3 tiles; a direct producer needs no reactor, so it is 2 when - * one cooler can cover one producer's whole waste. + * Both floors exist for one reason: cooling has to cross a tile boundary, so a + * producer needs a cooler beside it. A reactor + generator + cooler chain is + * 3 tiles; a direct producer needs no reactor, so it is 2 when one cooler can + * cover one producer's whole waste. * - * **Under a shared cooling pool that reason is gone and the floor is 1.** A lone - * tile takes a cooler, which pays into the pool for the whole board, or a direct - * producer, whose waste the board pays for. A lone generator or reactor stays - * worthless — heat is adjacency-bound under every rule — but the search works - * that out for itself and leaves the tile empty, which costs nothing. + * A shared cooling pool removes that reason, and removes the decomposition with + * it — the board is then one island and there is nothing here to apply. See + * `wholeBoardIsland`. */ -export function minIslandTiles( - canCoolDp: boolean, - anomaly?: AnomalyDefinition, -): number { - if (anomaly?.rule === "shared_cooling") return 1; +export function minIslandTiles(canCoolDp: boolean): number { return canCoolDp ? 2 : 3; } @@ -112,6 +106,66 @@ export function computeWaterAdjacency(grid: Tile[][]): Uint8Array { return flags; } +/** + * The whole board as a single island: every grass tile, with the board itself + * as the window. + * + * Under a shared cooling pool the components `splitGridIntoIslands` would + * produce are **not** independent — every cooler anywhere pays into one pool + * that every power source anywhere draws from — so solving them apart is + * simply wrong. Handing the board over whole makes the pool board-wide by + * construction, and costs nothing in the rest of the simulation: heat stays + * adjacency-bound because `runDistribution` already scopes its round budget and + * its repair to each connected component of the supplier/consumer graph, which + * is a finer partition than the island. `simulator.test.ts` is where that is + * pinned, for the whole-board context the readout has always used. + * + * The price is per-island parallelism: one island is one pool task, so a board + * of two large landmasses searches on one core where it could have used two. + * On the shipped maps that is a small loss — most are one landmass and a few + * scraps, so one island already holds about 95% of the budget — and it buys the + * whole board-level problem for nothing. Solving the components separately + * would mean describing each by a frontier of power against net cooling + * contributed and combining those under one scalar budget, which is a different + * and much larger machine. + * + * Every grass tile is included, however isolated: a lone tile takes a cooler + * that pays into the pool, or a direct producer the pool pays for, so there is + * no minimum size here to apply. + */ +function wholeBoardIsland(grid: Tile[][]): IslandSubGrid { + const height = grid.length; + const width = grid[0].length; + const buildable = new Uint8Array(width * height); + const originalTileIndices = new Int32Array(width * height); + const tiles: Tile[][] = []; + let tileCount = 0; + + for (let y = 0; y < height; y++) { + const row: Tile[] = []; + for (let x = 0; x < width; x++) { + const flat = y * width + x; + originalTileIndices[flat] = flat; + if (grid[y][x]?.type === "grass") { + buildable[flat] = 1; + tileCount++; + } + row.push({ x, y, type: grid[y][x].type }); + } + tiles.push(row); + } + + return { + width, + height, + grid: tiles, + buildable, + waterAdjacent: computeWaterAdjacency(grid), + tileCount, + originalTileIndices, + }; +} + /** * Splits the grid into independently solvable sub-grids of buildable tiles. * Components too small to ever be worth building on are dropped. @@ -128,9 +182,13 @@ export function splitGridIntoIslands( ): IslandSubGrid[] { if (!grid || grid.length === 0 || !grid[0] || grid[0].length === 0) return []; + // A shared cooling pool is the one rule that reaches across the whole board, + // so under it the board is one island and the decomposition does not apply. + if (anomaly?.rule === "shared_cooling") return [wholeBoardIsland(grid)]; + const originalHeight = grid.length; const originalWidth = grid[0].length; - const minTilesRequired = minIslandTiles(canCoolDp, anomaly); + const minTilesRequired = minIslandTiles(canCoolDp); const waterAdjacent = computeWaterAdjacency(grid); // 1. Only grass is buildable; everything else is an impassable boundary. diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts index d285857..7198340 100755 --- a/packages/solver/src/solver/simulate.ts +++ b/packages/solver/src/solver/simulate.ts @@ -210,17 +210,63 @@ export function simulateIsland( for (let i = 0; i < numCoolers; i++) heatOut[coolerTiles[i]] = 0.0; if (numWaste > 0) { - for (let i = 0; i < numCoolers; i++) { - d.supplierCap[i] = layout[coolerTiles[i]]!.effectiveValue; - } - for (let j = 0; j < numWaste; j++) { - d.consumerCap[j] = wasteOf[wasteTiles[j]]; + if (ctx.anomaly.rule === "shared_cooling") { + /* + * One pool for the whole board, handed out in proportion to what each + * power source is owed. Port of `CoolingNetwork.DistributeCryoArea`: + * adjacency stops mattering, and so do the fair split and the repair + * pass — there is one number and one rule for sharing it. + * + * Nothing downstream changes. Serving every source the same fraction + * means a short pool leaves *every* source under its waste, so the + * ordinary per-producer online test below turns that into the + * board-wide all-or-nothing the rule describes, with no second code + * path and no board-level flag. + * + * The 0.88 is already in each cooler's `effectiveValue` — the game rates + * the cooler down rather than charging it at the pool — so this sums + * what the buildings are worth, exactly as the local path does. + */ + let totalCooling = 0.0; + for (let i = 0; i < numCoolers; i++) + totalCooling += layout[coolerTiles[i]]!.effectiveValue; + + let totalDemand = 0.0; + for (let j = 0; j < numWaste; j++) totalDemand += wasteOf[wasteTiles[j]]; + + const factor = + totalDemand > 0 && totalCooling > 0 + ? totalCooling < totalDemand + ? totalCooling / totalDemand + : 1.0 + : 0.0; + + let totalAccepted = 0.0; + for (let j = 0; j < numWaste; j++) { + const accepted = wasteOf[wasteTiles[j]] * factor; + coolingIn[wasteTiles[j]] = accepted; + totalAccepted += accepted; + } + + // What each cooler is doing, for the readout: its own share of the work + // the pool actually did, which is the game's own reporting rule. + const share = + totalCooling > 0 && totalAccepted > 0 ? totalAccepted / totalCooling : 0; + for (let i = 0; i < numCoolers; i++) + heatOut[coolerTiles[i]] = layout[coolerTiles[i]]!.effectiveValue * share; + } else { + for (let i = 0; i < numCoolers; i++) { + d.supplierCap[i] = layout[coolerTiles[i]]!.effectiveValue; + } + for (let j = 0; j < numWaste; j++) { + d.consumerCap[j] = wasteOf[wasteTiles[j]]; + } + runDistribution(coolerTiles, numCoolers, wasteTiles, numWaste, ctx); + for (let i = 0; i < numCoolers; i++) + heatOut[coolerTiles[i]] = d.supplierSent[i]; + for (let j = 0; j < numWaste; j++) + coolingIn[wasteTiles[j]] = d.consumerReceived[j]; } - runDistribution(coolerTiles, numCoolers, wasteTiles, numWaste, ctx); - for (let i = 0; i < numCoolers; i++) - heatOut[coolerTiles[i]] = d.supplierSent[i]; - for (let j = 0; j < numWaste; j++) - coolingIn[wasteTiles[j]] = d.consumerReceived[j]; } let totalPower = 0.0; diff --git a/packages/solver/src/solver/types.ts b/packages/solver/src/solver/types.ts index d245ca3..34fbfed 100755 --- a/packages/solver/src/solver/types.ts +++ b/packages/solver/src/solver/types.ts @@ -227,17 +227,19 @@ export interface BaselineAnomaly extends AnomalyBase { } /** - * Cryo Nexus: every cooler on the island pays into one pool, taxed on the way - * in, and the pool reaches every producer regardless of adjacency. + * Cryo Nexus: every cooler on the board pays into one pool that reaches every + * producer regardless of adjacency, and each is re-rated on the way in. * - * This replaces the cooling distribution rather than scaling anything, which is - * why it carries no multiplier on `EffectiveBuilding` — `contribution` is a tax - * on what each cooler pays *into the pool*, not a re-rating of the cooler. + * Two rules in one, which is why this is the only shape that changes more than + * a building's figures. `coolerMultiplier` is an ordinary uniform scale on the + * cooler role — the game applies it to `CoolerBuilding.CoolingPerSec`, so it is + * what a cooler is *worth* and what the game shows for it, not a charge levied + * at the pool. The pool itself replaces the cooling half of the distribution. */ export interface SharedCoolingAnomaly extends AnomalyBase { rule: "shared_cooling"; - /** Fraction of its authored cooling each cooler contributes. */ - contribution: number; + /** What every cooler's cooling is multiplied by while this is running. */ + coolerMultiplier: number; } /** diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts index 95b3597..579b374 100644 --- a/packages/solver/tests/anomalies.test.ts +++ b/packages/solver/tests/anomalies.test.ts @@ -109,6 +109,133 @@ describe("scaling a resolved building", () => { }); }); +describe("a shared cooling pool reaching the board", () => { + /* + * The one rule that reaches across the whole board rather than a tile or a + * neighbourhood. Two halves: every cooler is re-rated x0.88 — the game + * applies that to `CoolingPerSec`, so it is what a cooler is worth — and the + * cooling half of the distribution is replaced by one pool that ignores + * adjacency entirely. + */ + const cryo = getAnomaly("cryo_nexus"); + // A cooler deliberately smaller than two generators' waste, so "the pool + // falls short" is a case these boards can actually reach: 30 x 0.88 is 26.4 + // against 25 of waste per generator. + const roster = basicRoster({ reactorValue: 500, coolerValue: 30 }); + const [REACTOR, GENERATOR, COOLER] = roster; + + const ctxFor = (rows: string[], anomaly = cryo) => + buildIslandContext(makeGrid(rows), undefined, anomaly); + + /** Place `spec` — `[x, y, building]` — rating each for its tile. */ + const score = ( + ctx: ReturnType, + spec: [number, number, EffectiveBuilding][], + ) => { + const at = new Map(); + for (let t = 0; t < ctx.n; t++) at.set(`${ctx.xs[t]},${ctx.ys[t]}`, t); + const placement: Placement = new Array(ctx.n).fill(null); + for (const [x, y, b] of spec) { + const tile = at.get(`${x},${y}`)!; + placement[tile] = ctx.rate(tile, b); + } + return simulateIsland(placement, ctx, true); + }; + + it("rates every cooler down, and nothing else", () => { + const ctx = ctxFor(["RRRR", "RGGR", "RRRR"]); + const tile = ctx.tiles[0]; + + expect(ctx.rate(tile, COOLER).effectiveValue).toBeCloseTo(30 * 0.88, 9); + // The authored tier is what identifies it and never moves. + expect(ctx.rate(tile, COOLER).baseValue).toBe(30); + expect(ctx.rate(tile, REACTOR)).toBe(REACTOR); + expect(ctx.rate(tile, GENERATOR)).toBe(GENERATOR); + }); + + it("cools a producer no cooler is anywhere near", () => { + /* + * The rule itself. The cooler is five tiles from the generator and on the + * far side of the board — under the base rules it reaches nothing and the + * generator is offline; pooled, distance stops existing. + */ + const rows = ["RRRRRRRR", "RGGGGGGR", "RRRRRRRR"]; + const spec: [number, number, EffectiveBuilding][] = [ + [1, 1, REACTOR], + [2, 1, GENERATOR], + [6, 1, COOLER], + ]; + + expect(score(ctxFor(rows, getAnomaly("none")), spec).totalPower).toBe(0); + expect(score(ctxFor(rows), spec).totalPower).toBeGreaterThan(0); + }); + + it("runs the board or none of it, never part", () => { + /* + * Every source is served the same fraction of what it is owed, so a short + * pool leaves all of them under their waste at once. There is no layout + * where one cluster is sustainable and another is not. + */ + const rows = ["RRRRRRRR", "RGGGGGGR", "RGGGGGGR", "RRRRRRRR"]; + + // Two independent hubs, one cooler between them — enough for one. + const short = score(ctxFor(rows), [ + [1, 1, REACTOR], + [2, 1, GENERATOR], + [5, 1, REACTOR], + [6, 1, GENERATOR], + [1, 2, COOLER], + ]); + expect( + short.placements.filter((p) => p.powerGenerated > 0).length, + "a short pool runs nothing at all", + ).toBe(0); + + // A second cooler, still nowhere near either generator, covers the board. + const covered = score(ctxFor(rows), [ + [1, 1, REACTOR], + [2, 1, GENERATOR], + [5, 1, REACTOR], + [6, 1, GENERATOR], + [1, 2, COOLER], + [2, 2, COOLER], + ]); + expect( + covered.placements.filter((p) => p.powerGenerated > 0).length, + "and a sufficient one runs both hubs", + ).toBe(2); + }); + + it("reports each cooler its share of the work the pool did", () => { + // The game's own reporting rule: a cooler is credited with its share of + // what the pool actually absorbed, not with its whole rating. + const ctx = ctxFor(["RRRRRR", "RGGGGR", "RGGGGR", "RRRRRR"]); + const out = score(ctx, [ + [1, 1, REACTOR], + [2, 1, GENERATOR], + [1, 2, COOLER], + [2, 2, COOLER], + ]); + + const waste = out.placements.reduce( + (sum, p) => sum + p.wasteHeatGenerated, + 0, + ); + const provided = out.placements.reduce( + (sum, p) => sum + p.coolingProvided, + 0, + ); + + expect(waste).toBeGreaterThan(0); + // The pool absorbed exactly the waste, and the two coolers split the + // credit for it evenly, being the same tier. + expect(provided).toBeCloseTo(waste, 6); + const each = out.placements.filter((p) => p.coolingProvided > 0); + expect(each.length).toBe(2); + expect(each[0].coolingProvided).toBeCloseTo(each[1].coolingProvided, 6); + }); +}); + describe("role isolation reaching the board", () => { /* * The one rule whose multiplier depends on the layout rather than on the diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index a634229..c6d0557 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -172,36 +172,36 @@ describe("the smallest patch worth keeping", () => { expect(minIslandTiles(true)).toBe(2); }); - it("is 1 tile under a shared cooling pool, whatever the roster", () => { - // Both ordinary floors exist because cooling has to cross a tile boundary. - // Pooled, it does not: a lone tile takes a cooler that pays into the pool, - // or a direct producer the pool pays for. - expect(minIslandTiles(false, cryo)).toBe(1); - expect(minIslandTiles(true, cryo)).toBe(1); - }); - - it("is unchanged by an anomaly that does not pool cooling", () => { - expect(minIslandTiles(false, getAnomaly("tidal_ascendancy"))).toBe(3); - expect(minIslandTiles(false, getAnomaly("none"))).toBe(3); - expect(minIslandTiles(false, undefined)).toBe(3); - }); - - it("keeps the patches it otherwise drops, under a shared pool", () => { + it("does not apply under a shared cooling pool — the board is one island", () => { /* - * The same board the roster above reduces to a single 3-tile island. These - * are tiles no other rule in the game can use: 21 of them across the - * shipped maps. + * The floors exist because cooling has to cross a tile boundary. Pooled it + * does not, and the components stop being independent at all, so the whole + * board is handed over as one island and every grass tile comes with it — + * including the ones a decomposition drops. 21 tiles across the shipped + * maps that no other rule in the game can use. */ const board = makeGrid(["G.GG.GGG"]); - expect( - splitGridIntoIslands(board, false).map((i) => i.tileCount), - ).toEqual([3]); - expect( - splitGridIntoIslands(board, false, cryo) - .map((i) => i.tileCount) - .sort((a, b) => a - b), - ).toEqual([1, 2, 3]); + expect(splitGridIntoIslands(board, false).map((i) => i.tileCount)).toEqual([ + 3, + ]); + + const pooled = splitGridIntoIslands(board, false, cryo); + expect(pooled.length, "one island, whatever the terrain").toBe(1); + expect(pooled[0].tileCount, "every grass tile, 1 + 2 + 3").toBe(6); + }); + + it("hands over the board's own dimensions and an identity remap", () => { + // The window is the board, so a placement needs no coordinate translation + // on the way back out. + const board = makeGrid(["GG.G", "GG.G"]); + const [island] = splitGridIntoIslands(board, false, cryo); + + expect([island.width, island.height]).toEqual([4, 2]); + expect(island.tileCount).toBe(6); + expect([...island.originalTileIndices]).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + // Water is still not buildable — one island is not one usable island. + expect([...island.buildable]).toEqual([1, 1, 0, 1, 1, 1, 0, 1]); }); }); From 97d7211a00c7c71e85abb573c4f8044d7249d092 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:10:55 +0300 Subject: [PATCH 10/21] Rate in the tile's units, and bound every rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the branch turned up one shape of mistake repeated across it: a figure resolved under an anomaly compared against a figure that was not. The rules were threaded correctly and applied correctly; what was missed is that once a tile can rate a building, every comparison against that building's figures has to be asked in the tile's units too. The bound was the reproducible case. `islandMaxScale` answered only for `terrain_affinity` and returned 1 for the rest, so an isolated generator's x2.5 intake sat outside it: a generator-bound roster on a 3x3 under Singularity read 163.8% layout efficiency, which makes the figure worthless rather than generous. It is now an exhaustive switch with no `default`, so a fifth rule shape fails to typecheck here instead of silently reading 1, and the isolation case returns the larger of the two role factors. Scaling the result is sound because `estimateIslandMaxPower` is homogeneous of degree 1 in the roster's figures, so scaling the answer is the same as scaling every figure — loose where only part of an island qualifies, which is the trade the function already took for terrain. The same case now reads 65.5%. Four sites in the search had the mismatch. The under-fed-reactor move compared a scaled delivery against an authored capacity, so 7675b1f's deliberate redefinition of `baseValue` left it flagging reactors only below 1/k fill — at Stellar Forge x5, below 20%. The report row gains `ratedValue` beside the authored value rather than the two sharing a field again. The retarget gate sized its targets off the plain roster and broke out of the loop on the first one, attempting 1 of 3 targets under Tidal against 3 of 3 at baseline; it now scales the target by what the island can rate. The six no-op-move guards compared base objects against rated ones and so stopped skipping, spending budget re-simulating layouts that had not changed. Right-sizing compared an isolation-rated load against unrated capacities and kept generators at tiers nothing needed — real money, invisible in power. None of it moves a fixture: every one of these is the identity at baseline, and the goldens regenerate byte for byte. The readout had the mismatch in the other direction, and it was the one a player could see. `effectiveAtValue` takes no anomaly, so the ceiling under a used figure was the authored tier: a shore cooler printed 8.35AC / 8AC, a used figure larger than the total it was measured against, and a Cryo cooler at full tilt could never reach its own. `ratedPlacementAt` answers from the scorer instead, which is also what resolves Singularity correctly — a rating that is a function of neighbours cannot be recomputed in a component. `uniformRating` meant `tileScale === null`, so it was true under Cryo while every cooler was scaled. Nothing read it, which is the only reason it was not already a bug; its docstring invites a stage to skip `rate` on the strength of it, which would have returned a layout 13.6% over-cooled on paper that the game shuts down board-wide. It now promises what its name does. The CLI wrote codes that did not say what they were solved under, so a Tidal layout previewed at roughly baseline; codes now carry rules, filenames carry the anomaly id, and every header names it. Both byte maps become closed unions, so an anomaly or a research added without a byte is a compile error rather than a share code silently written as `none`. On the tests: a mis-indexed read of `waterAdjacent` that forgot the window origin passed the entire suite, while mismatching 28 to 166 tiles on every shipped map — the two boards asserted against mask values both had a pad origin of 0. That is pinned against all eight codes now. The waste-composition test was a tautology that held under the regression it named, and the ordering CLAUDE.md calls load-bearing was pinned nowhere; both are on figures that differ in the last bit, where this catalogue's divergences actually live. The search now runs end to end under each rule, and every seed write is asserted rated. Two things are deliberately unchanged. `moveScale` is calibrated off the plain roster, so the walk runs colder under any anomaly — but the comment above it records that the previous hotter calibration was itself the bug, and this is a tuning constant, so it is documented as an open question with the measurement needed rather than changed blind. And the version byte is a one-way break: an older reader takes it for a width and decodes a 13x13 board as 1x13 without throwing. That is not fixable from this side; it is written down, along with the stored solves this release invalidates once. The retarget stage came back inside run-to-run noise on the seeds measured, so what it restores is the stage, not power. --- CLAUDE.md | 208 ++++++- docs/SOLVER.md | 197 ++++++- docs/game-logic.md | 33 +- packages/solver/bin/report.ts | 34 +- packages/solver/bin/solve.ts | 81 ++- packages/solver/src/data/prestige.ts | 35 +- packages/solver/src/encoding/blueprint.ts | 70 ++- packages/solver/src/solver/context.ts | 20 +- packages/solver/src/solver/island.ts | 93 +++- packages/solver/src/solver/placementSearch.ts | 236 +++++++- packages/solver/src/solver/simulate.ts | 38 +- packages/solver/src/solver/types.ts | 48 +- packages/solver/tests/anomalies.test.ts | 518 ++++++++++++++++-- packages/solver/tests/blueprint.test.ts | 78 +++ packages/solver/tests/island.test.ts | 232 +++++++- .../tests/placementSearch.rating.test.ts | 427 +++++++++++++++ packages/solver/tests/scaling.test.ts | 52 +- .../inspector/BoardStatsCard.svelte | 47 +- src/lib/simulation/simulator.test.ts | 165 +++++- src/lib/simulation/simulator.ts | 191 ++++++- src/lib/state/layout.svelte.ts | 23 +- src/lib/state/previewMode.test.ts | 73 ++- src/lib/state/solver.svelte.ts | 21 +- src/lib/worker/workerClient.ts | 20 +- 24 files changed, 2685 insertions(+), 255 deletions(-) create mode 100644 packages/solver/tests/placementSearch.rating.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4c59cca..d5e0c02 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,10 +100,13 @@ place that resolves them, so a panel cannot disagree with the layout it describes. It answers two questions that must not be confused: `getEffectiveBuildings` reads the player's _unlock level_ ("what may the solver build?"), while `effectiveAtValue` resolves a **placed** building's tier from -the value it was placed at ("what is this one rated for?"). The scorer and the -board readout both use the latter, so the ceilings the card prints are the tier -the building actually ran at — the two diverge the moment an upgrade is bought -behind a standing building. +the value it was placed at ("what is this one rated for?"). The scorer resolves a +placement through the latter and the board readout takes its ceilings from the +scorer, so the ceilings the card prints are the tier the building actually ran at +— the two diverge the moment an upgrade is bought behind a standing building. +The readout goes through the scorer rather than calling `effectiveAtValue` itself +because a tier is not the whole answer once an anomaly rates the tile as well; +see the readout's own section. **The catalogue is generated, so don't hand-edit it.** An external extraction script — it reads the game's own files and is not part of this repo — emits the @@ -134,8 +137,13 @@ Three things bind: - **Each is a uniform multiplier on the roles it names**, the same shape the stat anomalies turned out to have, so both go through - `scaleEffectiveBuilding`. Infinite Grid raises the overheat threshold as well - as the energy, which is what keeps it a bigger bet rather than free power. + `scaleEffectiveBuilding`. That is what keeps it from being free power: waste + is re-derived as `snap(heat - energy)` from the pair that was just scaled, so + a generator rated ×5 makes ×5 the waste and needs ×5 the cooling to stay + online. The overheat threshold Infinite Grid also names is **not** the reason + — it sizes a waste-heat *store* rather than the waste itself, and a + sustainable layout never fills it, so per `docs/game-logic.md` it is never the + binding constraint and `EffectiveBuilding` does not model it at all. - **It folds into the roster, not into the solver.** `prestigeScales()` resolves levels to one factor per role and `getEffectiveBuildings` applies it, so by the time an `EffectiveBuilding` crosses the worker boundary the research is already @@ -280,6 +288,19 @@ timeline. `docs/game-logic.md` is the authority on what each one does and brought across by hand. The game's `{0}`-templated strings land here with their `values` resolved, so the wording and the numbers the solver runs on cannot disagree. + + **What catches a half-finished job is the codec's byte maps.** They are keyed + on the closed id unions — `Record` and `PrestigeUpgradeId`, + the same shape and for the same reason — so an anomaly or a research added to + its table without a byte here fails to compile. Both failures are otherwise + silent in both directions: an anomaly would be written as 0 and a research + dropped altogether, and the recipient rates the board under rules its author + never ran with a perfectly confident figure printed for it. A compile error is + the only check that arrives before the code is shared. The encode-side + `?? none` fallback survives that and now covers only one case — a string that + is not an anomaly id at all, arriving off `localStorage` or the worker + boundary — which is the same total reading `getAnomaly` makes. Decode stays + tolerant: an unknown byte reads as no anomaly. - **`sidebar/AnomalySelector` is the only place it is chosen**, under the research on Setup's Time Lab tab. It mirrors a choice already made in the game rather than making one, so it is built to be recognised rather than shopped: icon and name first, the @@ -292,11 +313,13 @@ timeline. `docs/game-logic.md` is the authority on what each one does and changes which tier a *placement* resolves to (`rebasePlacements`), while an anomaly changes none of them — the same building at the same tier is simply rated differently — so it needs the re-score alone. -- **Tidal Ascendancy and Singularity Isolation are implemented; Cryo Nexus is - not.** The anomaly is threaded the whole way — `SolveOptions.anomalyId` / - `SolveRunOptions.anomalyId`, the worker request, and `IslandContext.anomaly` — - and each rule is resolved wherever its inputs are settled. Cryo is the - board-level problem above and nothing on screen says a solve ignored it. +- **All four entries are implemented**, `none` being the one with nothing to do + — it is an entry rather than an absence. The anomaly is threaded the whole way + — `SolveOptions.anomalyId` / `SolveRunOptions.anomalyId`, the worker request, + and `IslandContext.anomaly` — and each rule is resolved wherever its inputs + are settled: a terrain bonus per tile when the context is built, a role + isolation per layout inside `simulateIsland`, and a shared cooling pool in the + decomposition, before there is an island at all. **Singularity is resolved per layout, in `simulateIsland`, and it has to be.** A generator's rating is a function of what its neighbours *are*, so placing @@ -319,13 +342,22 @@ timeline. `docs/game-logic.md` is the authority on what each one does and Sizing the composition retarget from the isolated rating — the one stage that can propose a different mix, and the mix is what the bonus would need — was - measured and changed nothing on three seeds. It is not in the tree. + measured and changed nothing on three seeds. It is not in the tree: targets + are still scored from the plain roster, which is right, since which buildings + to use is a counting problem over the roster. Putting that stage's *gate* into + the layout's units is a different thing entirely and is below — it decides + whether the stage runs at all, not what it aims at. **A terrain bonus is resolved per tile, once, when the context is built.** Nothing about a layout can change which tiles qualify, so `terrainScales` settles it at construction and `ctx.rate(tile, building)` is a lookup — which - is why the rule costs the search nothing. `uniformRating` is true under every - other anomaly and `rate` is then the identity. + is why the rule costs the search nothing. `uniformRating` is the flag that + says `rate` is the **identity** — `ctx.rate(t, b) === b` for every tile and + every building — so a stage may skip the call entirely, and it therefore has + to promise the whole of `rate` rather than only its per-tile half: `rate` + carries a per-role factor beside its per-tile one, so the flag is false under + a terrain bonus *and* under a cooling pool's ×0.88, and true under `none` and + under Singularity, which `rate` does not answer at all. It is called where a building is **written onto a tile**, never where its figures are read: a step of the walk writes one or two tiles and then @@ -340,6 +372,25 @@ timeline. `docs/game-logic.md` is the authority on what each one does and back objects it was already given. Without it a restore would scale a scaled building and the layout would quietly be worth 2.8x. + **A placement holds rated buildings, so anything compared against one has to + be in the tile's units.** The roster is what every stage reaches for — the + candidate pools, a composition's score, the downgrade ladder — and none of + those figures mean what is on the board once a rule rates a tile above or + below the roster. Four comparisons turn on it and each fails silently: the + no-op guards (`ratedFor`, so a move that would write a tile what it already + holds is skipped rather than simulated for a delta of zero), the under-fed + reactor test (`isUnderFed`, against `ratedValue` — against the authored value + it only fires below 1/k fill and stops firing on almost everything it exists + for), the composition retarget's gate (`targetCanBeat`, which scales the + target by an island-wide `islandRatingCeiling`; the gate is a `break`, so + getting it wrong throws the whole stage away, and on island3 under Tidal it + never ran at any realistic budget), and right-sizing (`ratedCapacity`, which + has to ask `rateIsolated` rather than `rate`, or an isolated generator is + never offered the smaller tier that covers what it actually absorbs). + `docs/SOLVER.md` has the measurements; the retarget fix restored the stage + from 1 of 3 targets attempted to 3 of 3 with the power difference inside + run-to-run noise, so it buys back a stage rather than a number. + **A bonus is genuinely not free power here.** A shore generator makes x1.67 the waste while an inland cooler still covers x1, so a cluster straddling the shoreline goes offline — a layout optimised under the base rules scores @@ -355,13 +406,29 @@ timeline. `docs/game-logic.md` is the authority on what each one does and which tiles get claimed first. Don't re-attempt either without a wider measurement than three seeds at 15s. - **`estimateTotalMaxPower` takes the anomaly, and has to.** A terrain bonus - rates some tiles above the roster, so a bound computed on the plain roster is - one a real layout walks past — Magma Rift reported 119.9% layout efficiency - before it did. The bound rates a whole island at its best tile - (`islandMaxScale`), which is loose where only part of it qualifies and is the - right trade: a bound that can be beaten is worthless, one that is generous - only makes the efficiency figure read low. + **`estimateTotalMaxPower` takes the anomaly, and every rule of it.** Any rule + that rates a tile above the roster makes a bound computed on the plain roster + one a real layout walks past, and "no layout may ever beat it" is an invariant + the rest of the solver is entitled to assume: Magma Rift reported 119.9% + layout efficiency before the terrain case existed, and a generator-bound + roster on a 3x3 under Singularity read 163.8% before the isolation case did + (65.5% now). The bound rates a whole island at its best tile + (`islandMaxScale`), which is loose where only part of it qualifies — or where + only part of the roster is scaled — and is the right trade: a bound that can + be beaten is worthless, one that is generous only makes the efficiency figure + read low. Scaling the estimate's *result* is sound because it is positively + homogeneous of degree 1 in the roster's figures, which is also what lets a + rule scaling one role be answered with one island-wide number. + + **`islandMaxScale` is an exhaustive switch with no `default`**, so a fifth + rule shape fails to typecheck here rather than silently returning 1 — which is + precisely what `role_isolation` did while a layout beat the bound by 64%. It + allows both variants of an isolation multiplier, since which one a tile gets + is a function of the layout and a search free to keep generators apart rates + every one of them at the bonus; it is 1 for the shipped pool, whose ×0.88 only + ever costs cooling; and under a terrain list it errs high wherever the shore + mask cannot decide the question, which is any list that is not exactly + `["water"]`. The anomaly crosses the worker boundary **as an id**, resolved again on the far side, so the message stays a string rather than a table entry that has to @@ -501,6 +568,37 @@ Two rules follow, and they are not symmetric: permanent disagreement, so `persist()` calls `encodeBlueprint` directly while `exportBlueprint` (the share path) adds `placementTiers(placements)`. +**The CLI writes share codes, so it states rules too.** `codeFor` is the one code +writer all three of its paths go through and it always passes +`blueprintRules(anomalyId, {})` — an **empty** research table, which is a +statement and a true one rather than an omission: the CLI solves at full unlocks +and no Time Lab, and there is no flag for one, so "the author had no research" is +exactly what it knows, where leaving the section out would say "rules unknown" +and hand the reader their own timeline. `--anomaly` also tags the output filename +and every header names it (`docs/SOLVER.md` has the reasoning); `TEST_FILENAME_RE` +had to widen to match the tag, because that regex is what finds the previous ids +and a tagged file it could not see would restart numbering at 1 and overwrite an +untagged run. + +**Two consequences of the version byte are worth knowing before a release, and +neither is fixable in code.** + +- **It is a one-way break.** A reader written before the byte existed takes + `data[0]` as the width, so a new code's version byte of `1` makes a 13x13 board + decode as 1x13 — and the truncation guard passes, so nothing throws and a board + simply comes back wrong. That reaches a stale browser tab, a recipient on an + older deploy, and `localStorage` written by the new build and read by the old. + The other direction is the one that is right: a new reader **refuses** a version + it does not know rather than guessing, because the tiles are positional and a + misread produces a different board rather than an error. +- **Every stored solve is invalidated once, on upgrade.** `blueprintKey` now + includes the version byte and `solveSignature` gained the anomaly and the + research, so a record written before either stops matching. `restore()` clears + on a mismatch and says nothing, so a player who had a five-minute run saved per + island loses all of them at this release with no message. That is the correct + behaviour for a signature that cannot vouch for the board — but it happens once + and it happens silently, which is worth knowing rather than discovering. + **A share code has a second form: a link.** `encoding/shareLink.ts` owns the `?bp=` parameter's name so nothing else knows it, builds the URL from the live `location` (this app ships to a GitHub Pages subpath _and_ to localhost, so a @@ -644,6 +742,15 @@ context equals solving each island separately, which holds because distribution scopes its round budget and its repair to each connected component of the supplier↔consumer graph — a finer partition than the island. +It answers two questions and both go through one `layoutFor`: what every building +is doing (`simulatePlacedBuildings`) and what one of them was rated for +(`ratedPlacementAt`, which the readout's ceilings come from). A second copy of +that loop is precisely how the two would come to rate the same board differently. +The tiers it resolves are interned per (definition, tier value), because +`effectiveAtValue` mints a fresh record every call while the context's rating memo is +keyed on object identity — a guaranteed miss on a context kept alive for the +whole session, growing two dead entries per placement change. + Two details there are easy to trip over. `simulateIsland`'s `fullReport` argument turns off its "nothing can come online" short-circuits: the search wants power and stops early, but a player looking at a coolerless board still wants to see @@ -671,7 +778,7 @@ layout is stable" and "pruning a _stable_ layout never reduces power". ### Verifying a solver change -Start with `npm test`. Three parts of it are what actually catch a regression: +Start with `npm test`. Four parts of it are what actually catch a regression: - **`tests/fixtures.test.ts`** replays the golden fixtures in `packages/solver/fixtures/`. Two layers: `expected` (annealing off) is @@ -683,6 +790,22 @@ Start with `npm test`. Three parts of it are what actually catch a regression: tiles. **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`, + `tests/placementSearch.rating.test.ts` and the water-adjacency block in + `tests/island.test.ts` — is the only thing that can catch a rule regression at + all, because **every fixture is solved under the base rules**. Between them + they pin each rule reaching the layout the search reports and the search + running end to end under every one of them; the research-then-anomaly ordering + and the waste re-derived at each step, down to the last bit; a report row's two + capacity figures; the rating ceiling that keeps the composition retarget alive; + the no-op guards skipping rather than simulating on a rated tile; right-sizing + a layout whose ratings come from its own shape; that every building a seed + writes is already rated for the tile it landed on; and that each island's + `waterAdjacent` mask is translated by its window's own origin, checked against + all eight shipped codes. That last one is the shape of the whole problem: a + mis-indexed read passed the **entire** suite while mismatching between 28 and + 166 tiles per map, because a rule that is threaded but misapplied produces a + layout that is merely worth less than it says. Regenerate the fixtures with `npm run fixtures` and **read the diff** — it is the clearest statement of what a solver change actually did. Re-running against an @@ -1074,10 +1197,27 @@ and that column is what pays for the sprite beside it being 40px rather than 30p Each figure row is **used / total** — `10.1AC / 13.3AC` — because the live figure alone cannot distinguish a building doing nothing from one with little to do. The -ceiling comes from `effectiveAtValue`, so it is the tier the building was placed -at. **Cooling is the exception**: it is measured against the waste the building is -actually making, not the tier's full-tilt waste, because what it needs falls with -its fill — against the ceiling a half-fed generator would read as starved while +ceiling is the tier the building was placed at, **rated for the tile it stands +on**, and it comes from the scorer rather than from the roster: +`simulation/simulator.ts` exports `ratedPlacementAt`, which lays the whole board +out through the same `layoutFor` that scored the rows and hands back what that +tile was rated for. Two things force that. A placement carries only its +_authored_ tier value — rightly, since the tier is resolved back out of it — so +`effectiveAtValue` on its own answers a question with no tile in it: against the +plain roster a shore cooler printed `8.35AC / 8AC`, a used figure past the total +it was measured against, and a Cryo cooler at full tilt could never reach one. +And a role isolation rates a building by _what its neighbours are_, so the whole +board has to go in, not the one building — which is why the answer comes back +through `simulateIsland` and `ctx.ratedLayout` rather than from a second +neighbour scan written in the component. A second reading of the rules in the +card is exactly what would drift from the rows above it. The board it lays out is +the one this panel describes, and the rules are `layoutState`'s, which on a +previewed board are the author's. A tile the scorer cannot place gets no ceiling +at all rather than an invented one: every figure beside it is zero anyway. + +**Cooling is the one row not measured against a ceiling at all**: it is measured +against the waste the building is actually making, not the tier's full-tilt +waste, because what it needs falls with its fill — against the ceiling a half-fed generator would read as starved while being perfectly covered. That row turns **red** when cooling does not cover waste, and the test is `placementStatus` — the solver's own `wasteIsCovered` tolerance, not a bare `<`, which would paint running buildings red. @@ -1515,8 +1655,9 @@ It is **fully read-only**, and that is one rule with a lot of surfaces: preference moves. `importBlueprint` throws outright — it claims an island slot that `#saveState` would then refuse to write. - `rebasePlacements` sits it out. A previewed board is rated at **the author's** - tiers, read from the code's tier table, and it is the one board the reader's - roster does not speak for. This is the reason the tier table exists. + tiers, read from the code's tier table, and at the author's rules where the + code names them — it is the one board the reader's roster and the reader's + timeline do not speak for. This is the reason the tier and rules tables exist. - The HUD and the config panel are not rendered at all, so there is no Run button and no roster. @@ -1527,6 +1668,17 @@ theirs it is their roster that rates it — or `exitPreview()` simply leaves. An adoption that is refused (island cap, two transformers) puts the flag back and leaves them reading. `state/previewMode.test.ts` pins every refusal. +**The author's rules are dropped with the code, on both ways out, and restored +with it when an adoption is refused.** Exiting re-hydrates a board that was never +built under them, and an adopted board is re-based to the visitor's own unlocks, +so keeping the author's research would rate the visitor's own buildings under a +timeline they were never in. Every accessor that reads the rules branches on +`isPreview` first, so nothing would print a wrong figure today — but that makes +the guard the only thing standing between a re-scored board and someone else's +timeline, and there being nothing to guard is the stronger guarantee. On the +refused path the rules go back with the code, because the visitor is still +reading the author's board. + The state lives on `layoutState` rather than `uiState` because it is a property of the _document_: this board is not saved, not editable, and not the player's. diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 4c661a7..d4bf242 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -98,21 +98,49 @@ Two things about the shapes are load-bearing: covers every such anomaly there will be. It is not free power: a scaled producer needs proportionally more cooling and goes offline just as readily. - **Waste is derived, not scaled, and every factor has to be in hand before it is.** The game - recomputes `snapToAuthoredPrecision(heat − energy)` from the *fully scaled* heat and energy, so - the snap must happen once, after research and anomaly have both been applied — scaling the - authored waste instead disagrees in the last digit, and the fixtures assert exactly. That is - why an anomaly cannot simply be a second `scaleEffectiveBuilding` call layered on the prestige - one: the two factors have to arrive at a single multiply. See `docs/game-logic.md`. + **Waste is derived, not scaled, and it is re-derived at every step.** A scaled building's + waste is `snapToAuthoredPrecision(heat − energy)` recomputed from the pair that was just + scaled, never the authored waste carried through the same multiply — the snap is a decimal + rounding applied to the *difference*, so the two disagree in the last digit and the fixtures + assert exactly. Only the two roles that *have* waste derive one; `heat − energy` over a cooler + or a reactor would turn its whole output into waste. + + **Research and anomaly are two successive calls, research first, and that is not the same + double as one combined factor.** The game applies the Time Lab in the ScriptableObject getter + and the anomaly in the runtime getter, so what a building is rated at is + `(authored × research) × anomaly` — `scaleEffectiveBuilding` called once by + `getEffectiveBuildings` as it resolves the roster, and again by whatever applies the anomaly. + Generator7's first tier under ×5 then ×2.5 is 1.3875000000000001e22 against 1.3875e22 for + ×12.5, and the *order* is observable too: its fourth tier under ×5 then a ×1.67 shore is + 7.38975e22 where the reverse is 7.389749999999999e22. Each call re-derives the waste, so the + last one wins. `tests/scaling.test.ts` pins both readings against the shipped catalogue rather + than round numbers — every divergence here is in the last bit, where a test on tidy figures + passes under either. Nothing above the roster would notice a reversal: the fixtures pass no + anomaly, so they cannot see it either. See `docs/game-logic.md`. - **`role_isolation` depends on the layout, the other shapes do not.** A terrain bonus is fixed per tile and can be folded into the board; a generator's isolation multiplier changes every time the search moves a neighbour, so a placed building's figures have to be resolved per layout — which `simulateIsland` does, below. -`terrain_affinity` is the one rule implemented. `terrainScales` resolves it into a per-tile -multiplier when the context is built — nothing about a layout can change which tiles qualify — and -`IslandContext.rate(tile, building)` is then a lookup, so the rule costs the search nothing. -`uniformRating` is true under every other anomaly and `rate` is the identity. +**All four shapes are implemented**, `baseline` being the one that has nothing to do. Each is +resolved wherever its inputs are settled and nowhere else: a terrain bonus per tile as the context +is built, a role isolation per layout inside `simulateIsland`, and a shared cooling pool in the +decomposition, before there is an island at all. The three sections below take them in that order. + +`terrain_affinity` is resolved by `terrainScales` into a per-tile multiplier when the context is +built — nothing about a layout can change which tiles qualify — and `IslandContext.rate(tile, +building)` is then a lookup, so the rule costs the search nothing. + +`uniformRating` is the flag that says `rate` is the **identity**: `ctx.rate(t, b) === b` for every +tile and every building, so a stage may skip the call entirely. It is hoisted out of `rate` so the +common case is one boolean rather than a per-tile array read, and it has to promise the whole of +`rate`'s behaviour rather than only its per-tile half — `rate` carries a per-role factor beside its +per-tile one (see `shared_cooling` below), and a flag reading `tileScale === null` alone was true +under a pool while every cooler was being scaled by 0.88. A stage taking the invitation would then +have rated Cryo coolers at their unscaled figures and returned a layout over-cooled on paper that +the game shuts down board-wide, with nothing failing anywhere. So it is false under a tile scale +*and* under a role scale, and true under `baseline` and under `role_isolation` — which `rate` does +not answer at all, since `simulateIsland` resolves it through `rateIsolated`. Two things about where it is applied: @@ -129,6 +157,47 @@ Two things about where it is applied: comparing it, since the ladder is the plain roster while the building it is replacing and the load it measured are both in the tile's units. +**A placement holds rated buildings, so everything compared against one has to be in the tile's +units.** The roster is what every stage reaches for — pools of candidates, a composition's score, +the downgrade ladder — and none of those figures mean the same thing as what is on the board once a +rule rates a tile above or below the roster. Each of the four comparisons that gets this wrong +fails silently, and in a different way: + +- **The no-op guards.** A move asks "would writing this change anything?", and the answer has to be + `ratedFor(ctx, tile, candidate) === current[tile]` rather than a comparison against the plain + entry, which is never equal on a scaled tile. The guard then stops firing, the move writes back + an identical object, a full `simulateIsland` runs and `accept` is handed a delta of zero, which + it accepts. No number moves and the walk spends a slice of its budget on steps per second. The + comparison is exact because `ctx.rate` caches one object per (scale, roster entry) pair, which is + also what makes it a lookup rather than a multiply. +- **The under-fed-reactor move** (`isUnderFed`) measures what a supplier actually sent against + `ratedValue`, the capacity the tile was rated for. Against `baseValue` it compares a scaled + delivery with an unscaled ceiling and only calls a reactor under-fed below `1/k` fill — 60% on a + Tidal shore, 20% under a ×5 Stellar Forge — so the move stops firing on nearly everything it + exists for. +- **The composition retarget's gate** (`targetCanBeat`). A target is scored from the plain roster, + which is right — which buildings to use is a counting problem over the roster — but `bestPower` + is a rated layout's power, so the target is scaled by `islandRatingCeiling` before the two are + compared. The gate is a `break` over a descending list, so answering `false` once ends the stage + outright: on island3 under Tidal a 0.6s run already returns 1.68e23 against a top target of + 1.49e23, and the stage never ran at any realistic budget. Restoring it took the stage from 1 of 3 + targets attempted to 3 of 3; the power difference sat inside run-to-run noise, so this buys the + stage back rather than a number. +- **Right-sizing** (`ratedCapacity`) sizes a candidate the way the layout will rate it, which for + `role_isolation` means asking `rateIsolated` rather than `rate`. An isolated generator authored + 320, rated 800 and absorbing 300 was never offered the authored 120 tier that covers 300 at its + own rating, so the pass left 500 of intake nobody pays it to have — exactly the money the stage + exists to hand back. The other direction is caught by the re-simulation, so only the waste + escaped. + +`islandRatingCeiling` is one number for the island, resolved once per solve, and it is asked of the +**context** rather than read off the anomaly — so a rule resolved per tile and one resolved per +layout are both answered by the code that applies them, and a fifth rule shape needs nothing there. +Scaling a plain-roster figure by one island-wide factor is the same move the bound makes, sound for +the same homogeneity reason, and loose in the same place. Loose is the right way to be wrong here: +a ceiling too low skips a stage that would have helped, one too high costs a few arrangement +attempts that fail to beat the layout in hand. + **A terrain bonus is a harder search, not just a bigger number.** A shore generator makes x1.67 the waste while an inland cooler still covers x1, so a cluster straddling the coast goes offline — a layout optimised under the base rules scores *lower* re-rated under Tidal, and the search has to @@ -156,13 +225,37 @@ bites, and the penalty is avoidable by keeping generators apart. Map 3 at 15s co against a 1.4202e23 baseline, where the baseline layout *re-rated* under the anomaly is 1.2849e23 — so the search recovers most of the penalty and there is no gain to find. Sizing the composition retarget from the isolated rating, which is the only stage that can propose the different mix the -bonus would need, was measured and changed nothing. - -**The bound takes the anomaly too.** `estimateTotalMaxPower` rates each island at its best tile -(`islandMaxScale`), because a bound computed on the plain roster is one a bonused layout walks -past: Magma Rift reported 119.9% layout efficiency before it did, and nothing else in the solver is -entitled to assume the bound holds either. It is loose where only part of an island qualifies, -which is the right way to be wrong. +bonus would need, was measured and changed nothing. That is a different thing from putting the +stage's *gate* into the layout's units, which is above and is not a tuning choice: targets are +still scored from the plain roster, and nothing in the tree retargets toward the bonus. + +**The bound takes the anomaly too, and every rule of it.** `estimateTotalMaxPower` rates each +island at its best tile (`islandMaxScale`), because a bound computed on the plain roster is one a +rated layout walks past — and "no layout may ever beat it" is an invariant the rest of the solver +is entitled to assume, not a presentation detail. Magma Rift reported 119.9% layout efficiency +before the terrain case existed, and a generator-bound roster on a 3×3 under Singularity read +163.8% before the isolation case did; both are now the intended side of 100%, the 3×3 at 65.5%. + +`islandMaxScale` is an exhaustive switch with **no `default`**, deliberately: a fifth rule shape +added to `AnomalyDefinition` fails to typecheck here — "function lacks ending return statement" — +rather than silently returning 1, which is what `role_isolation` did while a layout was walking +past the bound by 64%. It answers `max(isolated, crowded, 1)` under a role isolation, because which +of the two variants a tile gets is a function of the layout rather than of the island and a search +free to keep generators apart rates every one of them at the bonus; `max(coolerMultiplier, 1)` +under a shared pool, which is 1 for the shipped ×0.88 and leaves the bound untouched; and the +multiplier under a terrain list it cannot fully decide. It can decide only a list that is exactly +`["water"]`, since the shore mask is the one thing that knows off-board counts as water, while +`terrainScales` tests rock and tree against the grid — so a landlocked island under a +`["water", "rock"]` anomaly errs high rather than staying at 1. + +Scaling the estimate's *result* is sound because `estimateIslandMaxPower` is positively homogeneous +of degree 1 in the roster's figures: multiply every one of them by `k` and each of `hFirst`, +`dRest`, `dFirst` and `hRest` scales by `k` while every ratio in it is invariant. That is also what +lets a rule scaling only *some* buildings be answered with one island-wide number — any layout +feasible under the partial scaling is feasible in the all-scaled world with an objective no larger. +It is loose where only part of an island qualifies, or where only part of the roster is scaled, +which is the right way to be wrong: a bound that can be beaten is worthless, one that is generous +only makes the efficiency figure read low. `shared_cooling` is the odd one out entirely: it is the only shape that changes more than a building's figures. **Its pool is the whole board** — the game's "island" is the map, Gale Hills @@ -204,6 +297,18 @@ It is worth real power, and most on a fragmented board, since the x0.88 has to b first: map 7 at 25s goes 271AC to 294AC, map 3 at 20s 139AC to 145AC, and map 1 — one landmass, already at 98% of its bound — is unchanged. +**One open question, deliberately left open.** `hillClimb`'s `moveScale` — the figure the annealing +temperature is derived from — is read off the **plain** roster: the top generator's energy at full +fill. Under any rule that rates a tile above the roster the moves the walk is judging are worth +more than that calibration assumes, so the walk runs colder than intended: up to ×1.67 under Tidal +and ×2.5 under Singularity. It is plausible that it costs something and nobody has measured how +much. It is left alone because the previous, *hotter* calibration was itself the bug the comment +above it records — scaling by total power made large islands accept ~23% of moves each costing 7% +of the layout, and the walk never climbed back — and because this repo does not move a tuning +constant on an argument. Settling it means the usual measurement: several seeds at a realistic +budget, on both a shore-heavy map under Tidal and a fragmented one under Singularity, against the +unchanged search. Don't change it blind, and don't assume it is fine because nothing fails. + ### `island.ts` — the window an island is solved in An `IslandSubGrid` is the component's bounding box **padded by one tile on every side**, carrying @@ -224,8 +329,17 @@ not in it — and an absent cell there is indistinguishable from the window's ow off-board counts as water, a shore flag resolved from inside an `IslandSubGrid` reads every border tile as inland, which on a custom island is the entire perimeter and the whole of the anomaly's effect. So `computeWaterAdjacency` runs **once on the full grid** before decomposition and each -sub-grid carries a copy indexed like `buildable`. `island.test.ts` pins it on a board where the -window is clamped against three edges. +sub-grid carries a copy indexed like `buildable`. + +**The copy is translated by the window's own origin, and that is the half worth a test.** The mask +is read at `origY * originalWidth + origX`, and a window clamped against the board on both axes has +a pad origin of (0, 0), so the offset cancels and a mis-indexed read is right by accident — which +is every small hand-built case. On the shipped boards it is not: a mis-indexed copy was measured +against the correct mask at between 28 and 166 mismatched tiles per map, which under Tidal is that +many tiles rated at the wrong multiplier on the boards players actually solve, with nothing +failing. The fixtures pass no anomaly and could not see it, and neither could anything else in the +suite. `island.test.ts` now pins the mask on a board clamped against three edges, on a component +inset from two, and on every island of all eight shipped codes. `splitGridIntoIslands` takes the anomaly for the same class of reason: `minIslandTiles` is a property of the rules in force rather than of the grid, so it cannot be applied after the split. @@ -268,8 +382,21 @@ produces an identical result — the out-of-order completion a worker pool actua ### `simulateIsland`'s `fullReport` flag `simulateIsland` short-circuits a layout that cannot bring anything online — no producers, or no -coolers — and returns rows carrying nothing but each building's base value. That is right for a -search, which only wants power and is about to throw the layout away. +coolers — and returns rows with every measured field zero, leaving each building's two capacity +figures and nothing else. That is right for a search, which only wants power and is about to throw +the layout away. + +**The two capacity figures are `baseValue` and `ratedValue`, and confusing them is silent.** +`baseValue` is the **authored** tier value and never moves, because a placement's tier is resolved +back out of it by matching the catalogue — report a scaled one and it matches a *higher* tier and +gets scaled a second time, which is wrong in the readout's ceilings, the `Lv.` chip and the tier +table of every share code at once. `ratedValue` is what the tile was actually rated for, which is +the figure every other number in the row was measured against; under a terrain bonus or a role +isolation the two differ by the multiplier. A consumer comparing a delivery against the wrong one +reads a full tile as a starved one or the reverse, which is exactly how the under-fed-reactor move +stopped firing. `ratedValue` lives on `SimPlacedBuilding` rather than on `PlacedBuilding`, so it +**does not cross the worker boundary**: it is the island's own units and means nothing to a caller +that does not hold its ratings. The app has a caller that wants more: `lib/simulation/simulator.ts` scores the board a player is looking at, and a board with no coolers on it still has heat moving through it that the inspector @@ -408,13 +535,22 @@ the change was intentional; that is the harness doing its job. ## The rest of the suite -The fixtures pin behaviour; the rest of `tests/` pins meaning. Two files are worth knowing about -before changing anything in the search: +The fixtures pin behaviour; the rest of `tests/` pins meaning. These are the ones worth knowing +about before changing anything in the search: - **`goldenLayouts.test.ts`** — proven optima for islands of 3 to 9 tiles, each confirmed by 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. +- **`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 + end to end under every one of them, the research-then-anomaly ordering and the re-derived waste + down to the last bit, a report row's two capacity figures, the island rating ceiling that keeps + the retarget stage alive, the no-op guards skipping rather than simulating on a rated tile, + right-sizing a layout whose ratings come from its own shape, and that every building a seed + writes is already rated for the tile it landed on. A rule that is threaded but never applied + fails nothing on its own, so this is the layer that notices. - **`distribution.test.ts`** — the worked examples from `docs/game-logic.md`, plus layouts built in the live game with per-building power read off the screen. Those are observations, not derivations: the spec was wrong about leftover handling until those runs corrected it. It also @@ -438,6 +574,21 @@ layout you cannot paste back into anything. Each run writes its **blueprint code instead, named after what it found (`3_island_7_2_91AC-296AB.txt`); paste one into the app's Import dialog to see the board. +**A code states the rules it was found under, and so does the filename.** `codeFor` is the one +code writer all three paths go through and it always passes `blueprintRules`, so the app's preview +rates a CLI layout the way the run did rather than under the reader's own timeline — a +coast-hugging Tidal board would otherwise come back at roughly its baseline figure with nothing +saying why. The research table it writes is **empty rather than absent**, which is a different +claim and a true one: the CLI solves at full unlocks and no Time Lab, and there is no flag for one, +so "the author had no research" is exactly what it knows, where an absent section would mean "rules +unknown". `--anomaly` also tags the filename (`test_4_tidal_ascendancy.txt`) and every header names +the timeline, because a `solves/` directory or a session scrollback holding three timelines is +otherwise unreadable from the outside — the same argument `formatNumberForFilename` makes about the +power figure. The tag is the full id, so a filename pastes straight back into the flag that +produced it, and it is empty under `none`, so existing names are unchanged. `TEST_FILENAME_RE` +matches the optional tail, since that regex is what finds the previous ids: a tagged filename it +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. diff --git a/docs/game-logic.md b/docs/game-logic.md index b2d3f6c..a00df24 100755 --- a/docs/game-logic.md +++ b/docs/game-logic.md @@ -464,28 +464,47 @@ wrong.** A power source's waste is never an independent figure: it is always the difference between its heat and its energy, recomputed from whatever those two currently are. So the order of operations for a generator or direct producer is: +The two multipliers also enter at **two different points**, and the waste is +re-derived at each. The Time Lab lands in the ScriptableObject getter and the +anomaly in the runtime getter that reads it, so the order of operations is: + ``` -heat = authoredHeat × timeLab × anomaly -energy = authoredEnergy × timeLab × anomaly -capacity = authoredCapacity × timeLab × anomaly -waste = snapToAuthoredPrecision(heat − energy) ← once, at the end + ← the ScriptableObject getter, Time Lab research +heat = authoredHeat × timeLab +energy = authoredEnergy × timeLab +capacity = authoredCapacity × timeLab +waste = snapToAuthoredPrecision(heat − energy) + + ← the runtime getter, the anomaly, reading the figures above +heat = heat × anomaly +energy = energy × anomaly +capacity = capacity × anomaly +waste = snapToAuthoredPrecision(heat − energy) ← again, from the new pair ``` Scaling the authored waste directly instead — `authoredWaste × timeLab × anomaly` — is wrong. It agrees to about fifteen digits and disagrees in the last, because the snap is a decimal rounding and it is applied to the *scaled* -difference, not carried along from the table. +difference, not carried along from the table. The same is true of carrying a +once-derived waste through the second multiply: the last derivation wins. Nothing else is snapped. Heat, energy, capacity and a cooler's cooling are left as raw products, because a multiplier is a runtime multiply rather than an -authored table entry; the derived waste is the single exception, and it is -snapped exactly once no matter how many multipliers went into it. +authored table entry; the derived waste is the only exception, and it is +re-derived wherever the pair it comes from is scaled again. **Anomaly and research compose multiplicatively.** A generator under Singularity Isolation (×2.5) with Infinite Grid maxed (×5) is rated ×12.5. That is a *rating*, not a promise of 12.5× the power: what it actually produces still depends on how much heat reaches it. +It is also not the same double as one ×12.5 multiply, and the difference is +real rather than pedantic: generator7's first tier is 1.3875000000000001e22 as +two successive scalings and 1.3875e22 as one combined factor. The **order** +matters for the same reason — its fourth tier under ×5 then a ×1.67 shore bonus +is 7.38975e22 where the reverse is 7.389749999999999e22 — so research first, +anomaly second, exactly as the two getters run. + And **overheat capacity scaling changes nothing for a sustainable layout**, since the store it sizes never fills. It is listed because the game lists it. diff --git a/packages/solver/bin/report.ts b/packages/solver/bin/report.ts index b0fcfa5..2da501e 100644 --- a/packages/solver/bin/report.ts +++ b/packages/solver/bin/report.ts @@ -2,11 +2,31 @@ * Console reporting: everything the CLI prints, and nothing the engine needs. */ +import { DEFAULT_ANOMALY_ID, getAnomaly } from "../src/data/anomalies"; import { formatNumber, formatNumberForFilename } from "../src/utils/formatters"; -import type { OptimizationResult } from "../src/solver/types"; +import type { AnomalyId, OptimizationResult } from "../src/solver/types"; import { formatDurationS, isTty } from "./status"; import type { CliMap } from "./maps"; +/** + * The line that names the timeline a run was solved under, or nothing under the + * base rules. + * + * Every path that prints a header prints this one, and that is the point: the + * power figures mean something different under each set of rules, so a session + * scrollback — or a `solves/` directory — of three timelines otherwise says + * nothing about which is which. Named only when there *is* an anomaly, so the + * ordinary line-up is unchanged. + * + * Research would belong here too and is absent because the CLI has no input for + * it: it solves at full unlocks and no Time Lab, which is what the codes it + * writes state. + */ +export function anomalyLine(anomalyId: AnomalyId | undefined): string | null { + if (!anomalyId || anomalyId === DEFAULT_ANOMALY_ID) return null; + return `Anomaly: ${getAnomaly(anomalyId).name}`; +} + /** * The same two tiers `formatNumberForFilename` spells, with a space where a * filename needs a hyphen. @@ -60,6 +80,7 @@ export function printRunComparison( sessionId: number, mapNum: number, runs: SolveRun[], + anomalyId?: AnomalyId, ): void { const powers = runs.map((r) => r.result.totalPower); const bestPower = Math.max(...powers); @@ -68,6 +89,8 @@ export function printRunComparison( console.log("=".repeat(70)); console.log(`RUN COMPARISON REPORT (ID: ${sessionId}, Map: ${mapNum})`); + const rules = anomalyLine(anomalyId); + if (rules) console.log(rules); console.log("=".repeat(70)); console.log( `${pad("Run #", 6)} | ${pad("Total Power", 16)} | ${pad("Active Tiles", 12)} | ${pad("Time (s)", 10)} | Status`, @@ -112,6 +135,8 @@ export interface SessionReport { estimatedMaxPower: number; topN: number; workers: number; + /** The timeline the session is solving under; absent means the base rules. */ + anomalyId: AnomalyId | undefined; powers: number[]; top: Candidate[]; elapsedS: number; @@ -124,8 +149,10 @@ export function sessionHeader(report: SessionReport): string { `${attempts} attempt${attempts === 1 ? "" : "s"} at a ` + `${formatDurationS(report.timeLimitS)} budget, ${report.workers} at a time`; const projected = (attempts * report.timeLimitS) / report.workers; + const rules = anomalyLine(report.anomalyId); return ( `Map ${gameMap.num} (${gameMap.name}): ${plan}, top ${report.topN} kept.\n` + + (rules ? `${rules}\n` : "") + `Roughly ${formatDurationS(projected)}. Ctrl-C stops and reports.\n` ); } @@ -176,6 +203,11 @@ export function printSessionReport(report: SessionReport): void { `SESSION REPORT -- map ${report.gameMap.num} (${report.gameMap.name})` + (report.interrupted ? " [interrupted]" : ""), ); + // Again in the closing block, not only in the opening one: a long session has + // scrolled its header away by the time the codes appear, and the codes are the + // part that gets copied out. + const rules = anomalyLine(report.anomalyId); + if (rules) console.log(rules); console.log(rule); console.log(`Estimated max: ${formatNumberPair(report.estimatedMaxPower)}`); diff --git a/packages/solver/bin/solve.ts b/packages/solver/bin/solve.ts index 4eaa8d2..04f47e7 100644 --- a/packages/solver/bin/solve.ts +++ b/packages/solver/bin/solve.ts @@ -31,15 +31,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { - ANOMALIES, - DEFAULT_ANOMALY_ID, - getAnomaly, -} from "../src/data/anomalies"; +import { ANOMALIES, DEFAULT_ANOMALY_ID } from "../src/data/anomalies"; import { BUILDINGS, allUpgradesUnlocked } from "../src/data/buildings"; import { getEffectiveBuildings } from "../src/data/effectiveBuildings"; import { blueprintKey, + blueprintRules, decodeBlueprint, encodeBlueprint, placementTiers, @@ -61,6 +58,7 @@ import { Leaderboard } from "./leaderboard"; import { DEFAULT_MAP_NUM, MAPS, MAPS_BY_NUM, type CliMap } from "./maps"; import { WorkerPool } from "./pool"; import { + anomalyLine, printRunComparison, printSessionReport, printSummary, @@ -107,7 +105,11 @@ const DEFAULT_TOP_N = 3; */ const SEED_STRIDE = 100003; -const TEST_FILENAME_RE = /^test_(\d+)\.txt$/; +// The optional tail is the anomaly tag `anomalyTag` appends. It has to be +// matched here rather than left to a looser pattern elsewhere: this regex is what +// finds the previous ids, so a tagged filename it could not see would restart +// numbering at 1 and overwrite an untagged run. +const TEST_FILENAME_RE = /^test_(\d+)(?:_[a-z_]+)?\.txt$/; const ISLAND_FILENAME_RE = /^(\d+)_island_\d+_\d+.*\.txt$/; function nextOutputId(pattern: RegExp): number { @@ -198,17 +200,53 @@ async function solveWithPool( return buildOptimizationResult(plan, results); } +/** + * A run's blueprint code — the one code writer all three CLI paths go through. + * + * It states the rules as well as the tiers, which a share code must: a code with + * no rules section decodes as "rules unknown", and the app's preview then rates + * the board under *no* anomaly and no research. A coast-hugging Tidal layout + * would come back at roughly its baseline figure with nothing on screen saying + * why. + * + * The research table is **empty rather than absent**, which is a truthful + * statement and not the same claim: the CLI solves at full building unlocks and + * no Time Lab (there is no flag for one), so "the author had no research" is + * exactly what it knows. `DEFAULT_ANOMALY_ID` spells the absence of `--anomaly` + * for the same reason — "no anomaly" is a real choice, and stating it is what + * keeps a reader off their own. + */ async function codeFor( grid: Tile[][], result: OptimizationResult, + anomalyId: AnomalyId | undefined, ): Promise { return encodeBlueprint( grid, result.placements, placementTiers(result.placements), + blueprintRules(anomalyId ?? DEFAULT_ANOMALY_ID, {}), ); } +/** + * The anomaly marked into a filename, or nothing under the base rules. + * + * The same argument `anomalyLine` makes about a header, applied to the artifact: + * `solves/` accumulates across runs and a code says nothing from the outside, so + * a directory of three timelines is unreadable without opening every file. The + * precedent is `formatNumberForFilename` and the app's `layoutImageFilename` — + * the figure that identifies an artifact belongs in its name. + * + * The full id rather than an abbreviation of it: there is no second table to + * keep in step, and it is what `--anomaly` takes, so a filename can be pasted + * back into the flag that produced it. Empty under `none`, so every existing + * filename is unchanged. + */ +function anomalyTag(anomalyId: AnomalyId | undefined): string { + return !anomalyId || anomalyId === DEFAULT_ANOMALY_ID ? "" : `_${anomalyId}`; +} + async function solveMap( gameMap: CliMap, unlockedUpgrades: Record, @@ -239,7 +277,7 @@ async function solveMap( runNum, result, elapsedS: (Date.now() - started) / 1000, - code: await codeFor(grid, result), + code: await codeFor(grid, result, anomalyId), }); } @@ -264,30 +302,25 @@ async function runSingle( pool: WorkerPool | null, ): Promise { const testId = nextOutputId(TEST_FILENAME_RE); + const filename = `test_${testId}${anomalyTag(anomalyId)}.txt`; const runs = await solveMap( gameMap, unlockedUpgrades, 1, timeLimitS, `Solving map ${gameMap.num} (test ID: ${testId})`, - () => `test_${testId}.txt`, + () => filename, seed, anomalyId, pool, ); const run = runs[0]; - // Named only when there is one, so the ordinary line-up is unchanged. The - // power figures mean something different under each set of rules, and a - // `solves/` directory of codes solved under three timelines otherwise says - // nothing about which is which. - if (anomalyId && anomalyId !== DEFAULT_ANOMALY_ID) - console.log(`Anomaly: ${getAnomaly(anomalyId).name}`); + const rules = anomalyLine(anomalyId); + if (rules) console.log(rules); printSummary(run.result, run.elapsedS); console.log(`Blueprint: ${run.code}`); - console.log( - `Test output written to: ${path.join(SOLVES_DIR, `test_${testId}.txt`)}\n`, - ); + console.log(`Test output written to: ${path.join(SOLVES_DIR, filename)}\n`); } async function runComparison( @@ -300,14 +333,15 @@ async function runComparison( pool: WorkerPool | null, ): Promise { const sessionId = nextOutputId(ISLAND_FILENAME_RE); + const tag = anomalyTag(anomalyId); for (let idx = 0; idx < gameMaps.length; idx++) { const gameMap = gameMaps[idx]; const filenameFor = (run: SolveRun, bestPower: number): string => { if (run.result.totalPower >= bestPower) - return `${sessionId}_island_${gameMap.num}_${run.runNum}_${formatNumberForFilename(bestPower)}.txt`; + return `${sessionId}_island_${gameMap.num}_${run.runNum}_${formatNumberForFilename(bestPower)}${tag}.txt`; const diff = formatNumberForFilename(bestPower - run.result.totalPower); - return `${sessionId}_island_${gameMap.num}_${run.runNum}_minus_${diff}.txt`; + return `${sessionId}_island_${gameMap.num}_${run.runNum}_minus_${diff}${tag}.txt`; }; const mapRuns = await solveMap( @@ -322,7 +356,7 @@ async function runComparison( pool, ); - printRunComparison(sessionId, gameMap.num, mapRuns); + printRunComparison(sessionId, gameMap.num, mapRuns, anomalyId); const best = mapRuns.reduce((a, b) => b.result.totalPower > a.result.totalPower ? b : a, ); @@ -355,6 +389,7 @@ async function runSession( estimatedMaxPower: 0, topN: args.top, workers: inFlight, + anomalyId: args.anomalyId, powers: [], top: [], elapsedS: 0, @@ -404,7 +439,7 @@ async function runSession( power: result.totalPower, attempt, activeTiles: result.activeTilesCount, - code: await codeFor(grid, result), + code: await codeFor(grid, result, args.anomalyId), key: blueprintKey(grid, result.placements), }; board.offer(candidate); @@ -481,7 +516,9 @@ options: accepted and ignored, exactly as in the app. This renders no picture. Each run writes its blueprint code to solves/, named -after what it found; paste one into the app's Import to see the board.`; +after what it found and after the anomaly it found it under; paste one into the +app's Import to see the board. The code states its tiers and its anomaly, so the +app rates the layout the way this run did.`; function parseArgs(argv: string[]): Args | null { const args: Args = { diff --git a/packages/solver/src/data/prestige.ts b/packages/solver/src/data/prestige.ts index 9b2b85c..18767a7 100644 --- a/packages/solver/src/data/prestige.ts +++ b/packages/solver/src/data/prestige.ts @@ -50,9 +50,16 @@ export const PRESTIGE_UPGRADES: readonly PrestigeUpgrade[] = [ description: "Improves all Generators and Wind Turbines, increasing Energy output, " + "Heat, and overheat thresholds.", - // The three figures it names are `EffectiveBuilding`'s three, so this is a - // uniform scale like the stat anomalies -- and like them, not free power: - // the overheat threshold it raises is the waste that must then be cooled. + // A uniform scale like the stat anomalies: energy and heat are two of + // `EffectiveBuilding`'s three figures, and the third -- waste -- is derived + // as `snap(heat - energy)` from the pair after they are scaled, so it grows + // with them. That is what keeps this from being free power: a generator + // rated x5 makes x5 the waste and needs x5 the cooling to stay online. + // + // The overheat threshold it also names is not modelled and is not the + // reason. It sizes a power source's waste-heat *store*, not the waste it + // makes, and a sustainable layout never fills it, so per + // `docs/game-logic.md` it is never the binding constraint. roles: ["generator", "direct_producer"], bonuses: [1.0, 1.5, 2.25, 3.0, 4.0], }, @@ -62,21 +69,21 @@ export const PRESTIGE_UPGRADES: readonly PrestigeUpgrade[] = [ effect: "Heat Output", description: "Boosts the Heat output of all Heat Producers.", /* - * Reactors only — and this is the **one uncertain call in this file**. + * Reactors only, and the boundary is the **class of building** rather than + * the catalogue's grouping. * * The game's own category `heat_producer` holds reactors *and* direct * producers (see `BuildingDefinition`), so "all Heat Producers" read - * literally would take the wind turbine too. Against that: `infinite_grid` - * above goes out of its way to name "Generators and Wind Turbines", which - * is only worth saying if the turbine is not already covered here, and - * scaling a direct producer's heat alone would be the only non-uniform - * effect in the game -- its energy and waste are authored separately and - * this names neither. + * literally would take the wind turbine too. It does not: a turbine's SO + * inherits `PowerSourceBuildingSO` and reads `infinite_grid`, while this + * research is read only by `HeatProducerBuildingSO`. That is why + * `infinite_grid` above goes out of its way to name "Generators and Wind + * Turbines" -- worth saying precisely because the turbine is not covered + * here. * - * If it does cover turbines, the cost of being wrong is close to nothing: - * the wind turbine is the weakest building in the roster and, per - * `docs/game-logic.md`, almost never earns a tile. Worth confirming in - * game all the same. + * So the rule is "every heat producer", and a reactor is the only one the + * shipped roster has; it would cover any other the game added. Modelling it + * as `["reactor"]` is that rule, not an approximation of it. */ roles: ["reactor"], bonuses: [1.0, 1.5, 2.25, 3.0, 4.0], diff --git a/packages/solver/src/encoding/blueprint.ts b/packages/solver/src/encoding/blueprint.ts index f6d4b25..399bdd3 100755 --- a/packages/solver/src/encoding/blueprint.ts +++ b/packages/solver/src/encoding/blueprint.ts @@ -3,7 +3,13 @@ import { levelIndexForValue, type BuildingId, } from "../data/buildings"; -import type { PlacedBuilding, Tile, TileType } from "../solver/types"; +import type { + AnomalyId, + PlacedBuilding, + PrestigeUpgradeId, + Tile, + TileType, +} from "../solver/types"; /** * Binary layout codec — terrain *and* buildings in one payload. @@ -134,15 +140,24 @@ export const MIN_GRID_DIM = 5; * 0 free — for anomalies it is the genuine "no anomaly" choice, and for * research it is a value no writer emits, so a stray zero decodes as nothing * rather than as the first upgrade. + * + * Both are keyed on their **closed id union** rather than on `string`, so an + * anomaly or a research added to `ANOMALIES` / `PRESTIGE_UPGRADES` without a + * byte here fails to compile. Those two tables are hand-owned — a new anomaly is + * brought across by hand — and the failure a missing byte produces is silent in + * both directions: an anomaly would be written as 0 and a research dropped + * altogether, so the recipient rates the board under rules its author never ran + * and a figure is printed for it either way. A compile error is the only check + * that arrives before the code is shared. */ -const ANOMALY_BYTE_MAP: Record = { +const ANOMALY_BYTE_MAP: Record = { none: 0, cryo_nexus: 1, tidal_ascendancy: 2, singularity_isolation: 3, }; -const RESEARCH_BYTE_MAP: Record = { +const RESEARCH_BYTE_MAP: Record = { absolute_zero: 1, infinite_grid: 2, stellar_forge: 3, @@ -386,8 +401,16 @@ function buildPayload( if (level === undefined) continue; research.push(byte, Math.max(0, Math.min(255, Math.round(level)))); } + /* + * The fallback can no longer be a shipped anomaly missing a byte — the map + * is keyed on `AnomalyId`, so that would not have compiled. What is left is + * a `rules.anomalyId` that is not an anomaly id at all, which is reachable + * because the field is a plain string: it arrives from `localStorage` and + * across the worker boundary. Writing 0 for one says "no anomaly", which is + * the same total reading `getAnomaly` makes of an id it does not know. + */ ruleBytes.push( - ANOMALY_BYTE_MAP[rules.anomalyId] ?? ANOMALY_BYTE_MAP.none, + ANOMALY_BYTE_MAP[rules.anomalyId as AnomalyId] ?? ANOMALY_BYTE_MAP.none, research.length / 2, ...research, ); @@ -622,23 +645,6 @@ export async function decodeBlueprint(code: string): Promise { * two, the first wins, which is the same arbitrary-but-stable answer the wire * format could hold anyway. */ -/** - * The rules record for a share code: the anomaly running and the research the - * player has. - * - * Beside `placementTiers` below and for the same reason — the app's Share - * button and the CLI both write share codes, and neither should be spelling - * this out itself. Research is copied rather than filtered: an id this build - * does not carry a byte for is simply dropped at encode time, which is where - * the format's own compatibility rule lives. - */ -export function blueprintRules( - anomalyId: string, - research: Record, -): BlueprintRules { - return { anomalyId, research: { ...research } }; -} - export function placementTiers( placements: readonly PlacedBuilding[], ): BlueprintTiers { @@ -651,3 +657,25 @@ export function placementTiers( } return tiers; } + +/** + * The rules record for a share code: the anomaly running and the research the + * player has. + * + * Beside `placementTiers` above and for the same reason — the app's Share button + * and the CLI both write share codes, and neither should be spelling this out + * itself. Research is copied rather than filtered: an id this build does not + * carry a byte for is simply dropped at encode time, which is where the format's + * own compatibility rule lives. + * + * An **empty** `research` is a statement, not an omission: it says the author + * had none, which is what the CLI (which has no research input) truthfully + * knows. Omitting the whole section instead would say "rules unknown" and leave + * a reader rating the board under its own timeline. + */ +export function blueprintRules( + anomalyId: string, + research: Record, +): BlueprintRules { + return { anomalyId, research: { ...research } }; +} diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index 68941a7..98cc69f 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -123,12 +123,22 @@ export interface IslandContext { */ readonly anomaly: AnomalyDefinition; /** - * True when every tile on this island rates a building the same way, which is - * every anomaly but a terrain bonus. + * True when `rate` is the identity: no tile and no role on this island rates a + * building as anything but itself, so `ctx.rate(t, b) === b` for every pair. * * Hoisted out of `rate` so the common case is a single boolean rather than a * per-tile array read, and so a stage can skip the call entirely where that - * reads better. + * reads better — which is exactly why the flag has to promise `rate`'s whole + * behaviour rather than only its per-tile half. It read `tileScale === null` + * once, which was true under a shared cooling pool while every cooler was + * being scaled by 0.88: a stage taking the invitation above would have rated + * Cryo coolers at their unscaled figures and returned a layout over-cooled on + * paper that the game shuts down board-wide, with nothing failing anywhere. + * + * So it is false under a terrain bonus (a per-tile scale) *and* under a role + * scale, and true under the rules that leave the roster alone — including + * `role_isolation`, which is resolved by `simulateIsland` through + * `rateIsolated` rather than by `rate`. */ readonly uniformRating: boolean; /** @@ -360,7 +370,9 @@ export function buildIslandContext( anomaly.rule === "shared_cooling" ? { cooler: anomaly.coolerMultiplier } : null; - const uniformRating = tileScale === null; + // Exactly `rate`'s own early-out below, so the flag cannot promise more than + // the call it stands in for delivers. + const uniformRating = tileScale === null && roleScale === null; // One cache per distinct scale, so a building is built at a given rating // once per island rather than once per placement. Keyed on the base object // because the roster is shared by every tile and its entries are stable for diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index 4bb9d16..3944ce7 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -376,6 +376,15 @@ function estimateIslandMaxPower( if (wasteRatio === Infinity || wasteRatio <= 0) wasteRatio = GENERATOR_WASTE_RATIO; + // The same 3-and-2 reasoning `minIslandTiles` owns — a reactor + generator + + // cooler chain is 3 tiles, a direct producer + cooler pair is 2 — restated + // here rather than read from it, because that function answers "is this patch + // worth keeping?" for a whole board and this asks "can this split produce + // anything?" for one island already kept. They must not be allowed to + // disagree: raise the floor there and these two numbers have to follow, or the + // bound goes on allowing a layout the decomposition no longer admits (which is + // still a bound, so nothing would fail) — or worse, lower it there and the + // bound becomes one a real layout beats. const canHub = rVal > 0 && gVal > 0 && buildableTiles >= 3; const canDp = hasDp && buildableTiles >= 2; if (!canHub && !canDp) return 0.0; @@ -448,31 +457,79 @@ function bestHeatForEngineTiles( * The largest multiplier any building on this island can be rated at. * * One number for the island rather than one per tile, because it is used to - * keep the bound a bound: the estimate below is homogeneous in the roster's - * figures — scale every one of them by `k` and the bound scales by `k` — so - * rating the whole island at its best tile is an upper bound on rating each - * tile at its own. + * keep the bound a bound: `estimateIslandMaxPower` is positively homogeneous of + * degree 1 in the roster's figures — `hFirst`, `dRest`, `dFirst` and `hRest` all + * scale by `k` and every ratio in it is invariant — so scaling its *result* by + * `k` is the same thing as scaling every roster figure by `k`, and rating the + * whole island at its best tile is an upper bound on rating each tile at its + * own. * - * It is loose by construction where only part of an island qualifies, and that - * is the right trade: a bound that can be beaten is worthless, while one that - * is generous only makes the efficiency figure read low. + * That argument is what lets a rule that scales only *some* buildings be + * answered with one island-wide number: any layout feasible under the partial + * scaling is feasible in the all-scaled world with an objective no larger. + * Under generator-only scaling, say, `H <= min(nReact * rVal, nGen * k * gVal) + * <= k * heatCap`, the cooling constraint at `k * dpWaste` is implied by the + * real one since `H / k <= H`, and the objective at `k * dpNet` dominates. + * + * It is loose by construction where only part of an island qualifies, or where + * only part of the roster is scaled, and that is the right trade: a bound that + * can be beaten is worthless, while one that is generous only makes the + * efficiency figure read low. + * + * The switch is exhaustive without a `default`, deliberately: a fifth rule shape + * added to `AnomalyDefinition` then fails to typecheck here — "function lacks + * ending return statement" — rather than silently defaulting to 1, which is what + * `role_isolation` did until a partial roster walked past the bound by 64%. */ function islandMaxScale( island: IslandSubGrid, anomaly?: AnomalyDefinition, ): number { - if (anomaly?.rule !== "terrain_affinity") return 1; - if (anomaly.multiplier <= 1) return 1; - // Only water can lie off the board, so the mask answers the whole question - // for the one terrain any shipped anomaly names; anything else is inside the - // window and would need the grid. Erring high keeps this a bound. - if (!anomaly.terrain.includes("water")) return anomaly.multiplier; - - for (let i = 0; i < island.buildable.length; i++) { - if (island.buildable[i] === 1 && island.waterAdjacent[i] === 1) - return anomaly.multiplier; + if (anomaly === undefined) return 1; + + switch (anomaly.rule) { + case "baseline": + return 1; + + case "shared_cooling": + // A scale on the cooler role alone. The shipped 0.88 only ever *costs* + // cooling, so this is 1 today and the bound is untouched; a pool that + // paid more than it took would raise the cooling budget, and by the + // homogeneity above that is bounded by scaling the whole roster. + return Math.max(anomaly.coolerMultiplier, 1); + + case "role_isolation": + // Both variants, because which one a tile gets is a function of the + // layout rather than of the island: `simulateIsland` re-rates a generator + // from what its neighbours are, so a search free to keep generators apart + // rates every one of them at `isolated`. The bound has to allow that even + // though it is the *penalty* that is usually binding — a roster whose + // generator intake is the short side of `min(nReact * rVal, nGen * gVal)` + // gets x2.5 of its heat cap, and a 3x3 layout beat the "TRUE upper bound" + // by 64% before this case existed. + return Math.max(anomaly.isolated, anomaly.crowded, 1); + + case "terrain_affinity": { + if (anomaly.multiplier <= 1) return 1; + // Water is the one terrain that can lie *off* the board, and the shore + // mask is the only thing that knows it — so the scan below answers the + // question exactly when water is the whole of the list. A list naming + // anything else qualifies a tile on a neighbour this window can see but + // the mask cannot speak for (`terrainScales` tests rock and tree against + // the grid), and one that mixed the two would need both answers at once. + // Erring high there keeps this a bound; a landlocked island under a + // `["water", "rock"]` anomaly would otherwise rate its tiles at the + // multiplier while the bound stayed at 1. + if (anomaly.terrain.length !== 1 || anomaly.terrain[0] !== "water") + return anomaly.multiplier; + + for (let i = 0; i < island.buildable.length; i++) { + if (island.buildable[i] === 1 && island.waterAdjacent[i] === 1) + return anomaly.multiplier; + } + return 1; + } } - return 1; } /** diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index 8960525..796e39b 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -133,7 +133,32 @@ function put( tile: number, building: EffectiveBuilding | null, ): void { - placement[tile] = building === null ? null : ctx.rate(tile, building); + 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 { @@ -216,6 +241,23 @@ function offlineProducers( 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 — 60% on a Tidal shore, 20% + * under a x5 Stellar Forge. The move then stops firing on almost everything 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, @@ -909,6 +951,13 @@ async function hillClimb( // 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 x1.67 shore + // is worth x1.67 of one here. Left alone deliberately: the previous, hotter + // calibration was itself the bug above, and nothing in this repo moves a tuning + // constant on an argument rather than a measurement. Whoever measures it should + // measure across several seeds and both anomalies. const moveScale = generators.length > 0 ? generators[0].effectiveValue * generatorEnergyRatio(generators[0]) @@ -982,7 +1031,7 @@ async function hillClimb( ) { uncooled.push(p); } - if (p.heatProduced > EPS && p.heatProduced < p.baseValue - EPS) { + if (isUnderFed(p)) { wastedReactors.push(p); } } @@ -993,7 +1042,9 @@ async function hillClimb( if (adj.length > 0) { const tile = adj[rng.int(adj.length)]; const old = current[tile]; - if (old !== topCooler) { + // 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)) @@ -1011,7 +1062,7 @@ async function hillClimb( if (adj.length > 0) { const tile = adj[rng.int(adj.length)]; const old = current[tile]; - if (old !== topGenerator) { + if (ratedFor(ctx, tile, topGenerator) !== old) { put(current, ctx, tile, topGenerator); const trial = simulateIsland(current, ctx); if (!accept(trial.totalPower, trial.placements)) @@ -1035,7 +1086,16 @@ async function hillClimb( const val1 = current[tile1]; const val2 = current[tile2]; - if (val1 === val2) continue; + // 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); @@ -1051,7 +1111,10 @@ async function hillClimb( const tile = rng.int(ctx.n); const old = current[tile]; const replacement = randomCandidate(pools, rng); - if (replacement === old) continue; + // 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); @@ -1173,6 +1236,87 @@ function targetCompositions( return scored.slice(0, topK); } +/** + * 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` + * already makes to keep its bound a bound (`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[], +): number { + if (ctx.uniformRating && ctx.isolation === null) 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) { + // 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; @@ -1333,11 +1477,15 @@ async function arrangeComposition( } const [a, b] = pairs[index]; - // Swapping identical buildings changes nothing. - if (current[a] === current[b]) continue; - 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); @@ -1438,7 +1586,10 @@ async function greedyPolish( const held = current[tile]; for (const building of candidates) { - if (building === held) continue; + // 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); @@ -1569,6 +1720,47 @@ function tileLoad(building: EffectiveBuilding, row: SimPlacedBuilding): number { 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 x2.5 load against a x1 capacity: an + * isolated generator authored 320, rated 800 and absorbing 300, was never + * offered the authored 120 tier that covers it at its own rating of 300, so the + * pass left 500 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. */ function downgradeTiers( effectiveBuildings: EffectiveBuilding[], @@ -1656,9 +1848,15 @@ export function downgradeOversized( // 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. + // 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; - if (!covers(rated.effectiveValue, load)) continue; + // 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); @@ -1931,6 +2129,14 @@ export async function solveIsland( deadlineMs, performance.now() + timeBudgetMs * COMPOSITION_SHARE, ); + // A target is scored from the plain roster and `bestPower` is a rated layout's + // power, so the skip test needs the two in the same units or it throws the + // whole stage away — see `islandRatingCeiling`. One number for the island, + // resolved once: the loop below runs three times. + const ratingCeiling = islandRatingCeiling( + ctx, + [reactors[0], generators[0], coolers[0]].filter((b) => b !== undefined), + ); for (const target of targetCompositions( ctx.n, reactors, @@ -1940,7 +2146,7 @@ export async function solveIsland( if ( performance.now() >= compositionDeadlineMs || pacer.stopRequested || - target.power <= bestPower + EPS + !targetCanBeat(target, bestPower, ratingCeiling) ) { break; } @@ -2080,7 +2286,11 @@ export const internals = { DOWNGRADE_ROLES, constructMultiStartSeed, downgradeTiers, + greedyPolish, hillClimb, + islandRatingCeiling, + isUnderFed, + targetCanBeat, offlineProducers, pruneDeadWeight, targetCompositions, diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts index 7198340..844274a 100755 --- a/packages/solver/src/solver/simulate.ts +++ b/packages/solver/src/solver/simulate.ts @@ -11,6 +11,26 @@ import type { EffectiveBuilding, PlacedBuilding, Placement } from "./types"; */ export interface SimPlacedBuilding extends PlacedBuilding { idx: number; + /** + * The capacity this building actually ran at on this tile — its + * `effectiveValue` as the layout rated it, anomaly and all. + * + * The pair with `baseValue` is the whole point of the field, and they must + * not be confused. `baseValue` is the **authored** tier value and never + * moves, because a placement's tier is resolved back out of it; this is what + * the tile was rated for, which is the figure every other number in the row + * was measured against. Under a terrain bonus or a role isolation rule the + * two differ by the multiplier, and a consumer comparing a delivery against + * the wrong one silently reads a full tile as a starved one (or the reverse): + * the walk's "under-fed reactor" move measured `heatProduced` against + * `baseValue` for a while and stopped firing below x1/k fill — 20% under a + * x5 research, 60% on a Tidal shore — with nothing failing anywhere. + * + * It stays on `SimPlacedBuilding` rather than on `PlacedBuilding`, so it does + * not cross the worker boundary: it is the search's own units, meaningless to + * a caller that does not hold the island's ratings. + */ + ratedValue: number; } export interface SimulationResult { @@ -251,9 +271,12 @@ export function simulateIsland( // What each cooler is doing, for the readout: its own share of the work // the pool actually did, which is the game's own reporting rule. const share = - totalCooling > 0 && totalAccepted > 0 ? totalAccepted / totalCooling : 0; + totalCooling > 0 && totalAccepted > 0 + ? totalAccepted / totalCooling + : 0; for (let i = 0; i < numCoolers; i++) - heatOut[coolerTiles[i]] = layout[coolerTiles[i]]!.effectiveValue * share; + heatOut[coolerTiles[i]] = + layout[coolerTiles[i]]!.effectiveValue * share; } else { for (let i = 0; i < numCoolers; i++) { d.supplierCap[i] = layout[coolerTiles[i]]!.effectiveValue; @@ -310,7 +333,11 @@ export function simulateIsland( return { totalPower, placements }; } -/** Report rows for a layout that produces nothing — every field but the base value is zero. */ +/** + * Report rows for a layout that produces nothing — every measured field is + * zero, leaving each building's two capacity figures (`baseValue` and the + * rating it would have run at) and nothing else. + */ function inertPlacements( placement: Placement, ctx: IslandContext, @@ -344,6 +371,11 @@ function row( // The authored tier value, never the scaled one — a placement's tier is // resolved back out of this. See `EffectiveBuilding.baseValue`. baseValue: b.baseValue, + // ...and beside it what the tile was actually rated for, which is the + // figure the rest of this row was measured against. `b` is the building as + // the layout holds it, so this is already the scaled one wherever a rule + // scaled it. + ratedValue: b.effectiveValue, powerGenerated, heatProduced, heatConsumed, diff --git a/packages/solver/src/solver/types.ts b/packages/solver/src/solver/types.ts index 34fbfed..388df1c 100755 --- a/packages/solver/src/solver/types.ts +++ b/packages/solver/src/solver/types.ts @@ -165,8 +165,25 @@ export interface EffectiveBuilding { * which the UI numbers 1. An upgrade absent from the player's record is not * researched at all and multiplies by 1, so there is no zero entry. */ +/** + * Which Time Lab research an entry is, as a closed union — the same shape + * `AnomalyId` is, and for the same reason: the blueprint codec keeps a byte per + * id, and a research the table has no byte for is dropped from a share code + * *silently*. Closing the union makes a missing byte a compile error instead, + * which is the only check that fires before the code is written rather than + * after somebody reads it. + * + * Only the three the solver can feel are here; `PRESTIGE_UPGRADES` lists the + * seven that are not and why. Adding one to the table therefore means naming it + * here, and naming it here means giving it a byte. + */ +export type PrestigeUpgradeId = + | "absolute_zero" + | "infinite_grid" + | "stellar_forge"; + export interface PrestigeUpgrade { - id: string; + id: PrestigeUpgradeId; name: string; /** The game's own label for what it changes, e.g. "Cooling Output". */ effect: string; @@ -402,20 +419,31 @@ export interface SolveOptions { /** * The timeline's anomaly, by id. Omitted means the base rules. * - * Accepted but **not yet honoured** — the rules are modelled and threaded, - * not implemented; see `docs/game-logic.md`. Time Lab research is a different - * matter and already lands, because it resolves into the roster before a - * solve ever starts. + * Honoured, and each rule is resolved wherever its inputs are settled rather + * than in one place: a terrain bonus per tile as `IslandContext` is built (so + * it costs the search nothing), a role isolation per layout inside + * `simulateIsland` (a building's rating is a function of what its neighbours + * *are*, so one write re-rates up to eight tiles), and a shared cooling pool + * in the decomposition — `splitGridIntoIslands` stops producing islands and + * hands the board over whole, which is what keeps "islands never interact" + * true under a rule that pools across the map. + * + * It is an id rather than the resolved definition because the same field has + * to cross the worker boundary below, where a string survives cloning and a + * table entry is something to keep in step. `getAnomaly` is total, so the far + * side resolving an id it does not know gets the baseline rather than a + * crash. See `docs/game-logic.md` for what each rule does. */ anomalyId?: AnomalyId; /** * Time Lab research, already folded to one factor per role. * - * Unlike the anomaly this **is** honoured, because it needs nothing from the - * search: it resolves into the roster here and the `EffectiveBuilding[]` that - * crosses the worker boundary already carries it in its three numbers. That - * is also why the worker protocol below has no field for it — sending it - * again would be sending it twice. + * Unlike the anomaly it needs nothing from the search at all: it resolves + * into the roster here, and the `EffectiveBuilding[]` that crosses the worker + * boundary already carries it in its three numbers. That is why the worker + * protocol below has no field for it — sending it again would be sending it + * twice — and why the anomaly, whose rules need a tile, a layout or the whole + * board, has to be sent instead of folded in. */ prestige?: PrestigeScales; } diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts index 579b374..6f0d64d 100644 --- a/packages/solver/tests/anomalies.test.ts +++ b/packages/solver/tests/anomalies.test.ts @@ -16,23 +16,47 @@ import { DEFAULT_ANOMALY_ID, getAnomaly, } from "../src/data/anomalies"; -import { scaleEffectiveBuilding } from "../src/data/effectiveBuildings"; +import { BUILDINGS } from "../src/data/buildings"; +import { + getEffectiveBuildings, + scaleEffectiveBuilding, +} from "../src/data/effectiveBuildings"; +import { prestigeScales } from "../src/data/prestige"; import { wasteIsCovered } from "../src/solver/physics"; -import { buildIslandContext } from "../src/solver/context"; +import { buildIslandContext, type IslandContext } from "../src/solver/context"; +import { EPS } from "../src/solver/constants"; import { simulateIsland } from "../src/solver/simulate"; import { makeGrid } from "../src/grid"; -import { splitGridIntoIslands } from "../src/solver/island"; +import { + canCoolDirectProducer, + estimateTotalMaxPower, + splitGridIntoIslands, +} from "../src/solver/island"; import { solveIsland } from "../src/solver/placementSearch"; -import { basicRoster, cooler } from "./helpers"; -import type { EffectiveBuilding, Placement } from "../src/solver/types"; - +import { basicCatalogue, basicRoster, cooler } from "./helpers"; +import type { + AnomalyDefinition, + EffectiveBuilding, + IslandSubGrid, + Placement, + RoleIsolationAnomaly, +} from "../src/solver/types"; + +/* + * A generator whose figures part company under the shipped x1.67: the pair + * scales to 167 and 100 x 1.67 is 167.00000000000003, so a test on it can tell + * a derived waste from a scaled one. `scaleEffectiveBuilding`'s own file pins + * that against the real catalogue; this fixture is here so the cases below, + * which are about the other three figures, cannot pass under the rule this one + * replaced. + */ const generator: EffectiveBuilding = { id: "gen", type: "generator", - effectiveValue: 400, - energy: 300, - waste: 100, - baseValue: 400, + effectiveValue: 100, + energy: 70, + waste: 30, + baseValue: 100, }; describe("the anomaly catalogue", () => { @@ -61,33 +85,35 @@ describe("the anomaly catalogue", () => { }); describe("scaling a resolved building", () => { - it("scales all three figures by the same factor", () => { - expect(scaleEffectiveBuilding(generator, 2.5)).toEqual({ - id: "gen", - type: "generator", - effectiveValue: 1000, - energy: 750, - waste: 250, - // Untouched — see below. - baseValue: 400, - }); - }); + /* + * `baseValue` staying authored and the by-reference identity at a factor of + * one are pinned in `scaling.test.ts`, which is the file dedicated to this + * function and states them against the shipped catalogue. What is here is the + * half the anomalies care about: that a bonus reaches every figure a role has, + * so it is never free power. + */ - it("leaves the authored tier value alone", () => { - // The one field that must NOT scale. It is what a placement records and - // what `effectiveAtValue` resolves the tier back out of, so scaling it - // matches a higher tier and then applies the factor a second time: an - // authored 400 under x2 would report 800, match the authored 800 tier, and - // come back 1600 at one level too high. - const scaled = scaleEffectiveBuilding(generator, 2); - expect(scaled.baseValue).toBe(generator.baseValue); - expect(scaled.effectiveValue).toBe(800); - }); + it("scales the pair and derives waste from it, rather than scaling waste", () => { + /* + * The contract that replaced "scales all three figures by the same factor". + * Waste is the game's runtime getter over the scaled pair — + * `snapToAuthoredPrecision(heat - energy)` — not the authored waste put + * through the same multiply, and the two disagree in the last digit where + * the fixtures assert exactly. + * + * The old test survived the change only because its fixture happened to + * satisfy `waste === snap(heat - energy)` at its chosen factor, so it read + * as a statement about scaling and could not fail if scaling came back. + */ + const scaled = scaleEffectiveBuilding(generator, 1.67); - it("is identity at a factor of one", () => { - // By reference: the unscaled case is most of every board and must allocate - // nothing. - expect(scaleEffectiveBuilding(generator, 1)).toBe(generator); + expect(scaled.effectiveValue).toBe(167); + expect(scaled.energy).toBeCloseTo(116.9, 9); + expect(scaled.waste).toBe(50.1); + // Scaling the authored waste instead gives 50.099999999999994. + expect(scaled.waste).not.toBe(generator.waste * 1.67); + // And the authored tier value never moves, whatever the factor. + expect(scaled.baseValue).toBe(100); }); it("leaves a bonus needing proportionally more cooling", () => { @@ -455,12 +481,35 @@ describe("a terrain bonus reaching the board", () => { }); it("leaves every tile alone under an anomaly that is not terrain-based", () => { - for (const id of ["none", "cryo_nexus", "singularity_isolation"]) { + // Both of these leave the roster exactly as authored: the baseline does + // nothing, and role isolation is resolved per layout by `simulateIsland` + // through `rateIsolated`, never by `rate`. + for (const id of ["none", "singularity_isolation"]) { const ctx = contextFor(["GGGGG", "GGGGG"], getAnomaly(id)); + const base = cooler(100); expect(ctx.uniformRating, id).toBe(true); + expect(ctx.rate(ctx.tiles[0], base), id).toBe(base); } }); + it("is not uniform under a shared cooling pool, which scales a role", () => { + /* + * `uniformRating` is the promise that `rate` is the identity, and it invites + * a stage to skip the call — so it has to cover the role scale as well as + * the per-tile one. It read `tileScale === null` once, which said "uniform" + * while every cooler on the board was being rated x0.88: a stage taking the + * invitation would have returned a layout 13.6% over-cooled on paper that + * the game shuts down board-wide, with nothing failing. + */ + const ctx = contextFor(["GGGGG", "GGGGG"], getAnomaly("cryo_nexus")); + const base = cooler(100); + + expect(ctx.uniformRating).toBe(false); + expect(ctx.rate(ctx.tiles[0], base).effectiveValue).toBeCloseTo(88, 9); + // Only the cooler role, and the same answer on every tile. + expect(ctx.rate(ctx.tiles[1], base)).toBe(ctx.rate(ctx.tiles[0], base)); + }); + it("reports a power the same layout re-rates to", async () => { /* * The agreement between the solver and everything that reads its output. @@ -538,3 +587,400 @@ describe("a terrain bonus reaching the board", () => { expect(shore).toBeCloseTo(inland * 1.67, 6); }); }); + +describe("role isolation for a role that is not the generator", () => { + /* + * `simulateIsland` dispatches the rule over four role arms, and the shipped + * table names only one of them: `singularity_isolation` is `role: "generator"`, + * so three quarters of that dispatch runs nowhere in the suite and nowhere in + * the app. A future entry naming coolers would pick the wrong tile list in + * silence — a cooler rule applied to the generators rates every building on + * the board, and the board simply comes back worth something else. + * + * `ANOMALIES` is hand-owned and must not gain a fake entry for a test, so the + * definition is built here, as the mixed-terrain bound case does. + */ + const ROSTER = basicRoster({ + reactorValue: 500, + generatorValue: 100, + coolerValue: 200, + dpValue: 120, + dpWasteRatio: 0.2, + }); + const CODES: Record = { + r: ROSTER[0], + g: ROSTER[1], + c: ROSTER[2], + d: ROSTER[3], + }; + + /** Singularity's own shape with the role swapped out, and nothing else. */ + const isolating = ( + role: EffectiveBuilding["type"], + ): RoleIsolationAnomaly => ({ + ...(getAnomaly("singularity_isolation") as RoleIsolationAnomaly), + role, + }); + + /** Each occupied tile's authored value and the value it was rated at. */ + const ratings = ( + spec: readonly [number, number, string][], + anomaly: AnomalyDefinition, + ) => { + const ctx = buildIslandContext( + makeGrid(["GGGGG", "GGGGG"]), + undefined, + anomaly, + ); + const at = new Map(); + for (let t = 0; t < ctx.n; t++) at.set(`${ctx.xs[t]},${ctx.ys[t]}`, t); + + const placement: Placement = new Array(ctx.n).fill(null); + for (const [x, y, code] of spec) + placement[at.get(`${x},${y}`)!] = CODES[code]; + + const out = new Map(); + for (const row of simulateIsland(placement, ctx, true).placements) + out.set(`${row.x},${row.y}`, { + base: row.baseValue, + rated: row.ratedValue, + }); + return out; + }; + + /* + * The same picture per role: two of the isolated role touching at (0,0) and + * (1,0), a third alone at (4,0), and a pair of some *other* role on the row + * below as the control — it must come back rated exactly as authored, which is + * what fails if the dispatch reads the wrong tile list. + */ + const CASES: [EffectiveBuilding["type"], string, string][] = [ + ["cooler", "c", "g"], + ["reactor", "r", "c"], + ["direct_producer", "d", "c"], + ]; + + for (const [role, code, control] of CASES) { + it(`rates a lone ${role} up and a touching pair down`, () => { + const rated = ratings( + [ + [0, 0, code], + [1, 0, code], + [4, 0, code], + [0, 1, control], + [1, 1, control], + ], + isolating(role), + ); + + for (const key of ["0,0", "1,0"]) { + const row = rated.get(key)!; + expect(row.rated, `${key} touches another ${role}`).toBeCloseTo( + row.base * 0.8, + 6, + ); + } + const lone = rated.get("4,0")!; + expect(lone.rated, "three tiles from the nearest one").toBeCloseTo( + lone.base * 2.5, + 6, + ); + + // The control pair, of a role the rule does not name: untouched however + // tightly it is packed. + for (const key of ["0,1", "1,1"]) { + const row = rated.get(key)!; + expect(row.rated, `${key} is a ${control}, not a ${role}`).toBe( + row.base, + ); + } + }); + } +}); + +describe("one island context scoring one layout after another", () => { + /* + * `ctx.ratedLayout` is held by the island rather than allocated per call, so + * every `simulateIsland` under a role-isolation rule writes into the same + * buffer the last one left behind. Two things keep that honest today: the + * whole board is copied in on entry, and the neighbour scan reads `placement` + * rather than the buffer it is filling. Either alone is harmless; drop both — + * narrow the copy to "the tiles this rule touches" and let the scan read the + * buffer, which is the natural pair of optimisations — and a tile emptied + * between two calls keeps the building the previous layout had there, which + * changes what its *neighbours* are rated at. + * + * Nothing else in the suite runs two layouts through one context, and the leak + * would be invisible: the report carries no row for the empty tile, so the + * board looks right and is simply worth the wrong amount. + */ + const singularity = getAnomaly("singularity_isolation"); + const [R, G, C] = basicRoster({ + reactorValue: 500, + generatorValue: 100, + coolerValue: 200, + }); + + const layoutOn = ( + ctx: IslandContext, + spec: readonly [number, number, EffectiveBuilding][], + ): Placement => { + const at = new Map(); + for (let t = 0; t < ctx.n; t++) at.set(`${ctx.xs[t]},${ctx.ys[t]}`, t); + const placement: Placement = new Array(ctx.n).fill(null); + for (const [x, y, b] of spec) placement[at.get(`${x},${y}`)!] = b; + return placement; + }; + + const CROWDED: [number, number, EffectiveBuilding][] = [ + [0, 0, R], + [1, 0, G], + [2, 0, G], + [3, 0, R], + [1, 1, C], + [2, 1, C], + ]; + // The same board with (2, 0) emptied, which leaves the generator at (1, 0) + // with no generator beside it. + const LONE = CROWDED.filter(([x, y]) => !(x === 2 && y === 0)); + + it("forgets the building a tile held in the previous layout", () => { + const board = makeGrid(["GGGG", "GGGG"]); + const shared = buildIslandContext(board, undefined, singularity); + + // The crowded layout first, so the buffer holds a generator at (2, 0). + const crowded = simulateIsland(layoutOn(shared, CROWDED), shared, true); + expect( + crowded.placements.find((r) => r.x === 1 && r.y === 0)!.ratedValue, + "two generators touching", + ).toBeCloseTo(80, 6); + + // Then the same board with that tile empty, through the same context. + const lone = simulateIsland(layoutOn(shared, LONE), shared, true); + const row = lone.placements.find((r) => r.x === 1 && r.y === 0)!; + + expect(row.ratedValue, "nothing beside it any more").toBeCloseTo(250, 6); + expect(lone.placements.some((r) => r.x === 2 && r.y === 0)).toBe(false); + + // And the whole report is what a context that had never seen the first + // layout produces — the property, rather than one figure off it. + const fresh = buildIslandContext(board, undefined, singularity); + const alone = simulateIsland(layoutOn(fresh, LONE), fresh, true); + expect(lone.totalPower).toBe(alone.totalPower); + expect(lone.placements).toEqual(alone.placements); + }); +}); + +describe("the search running under each rule", () => { + /* + * Every other `solveIsland` call in the suite is baseline or Tidal, so three of + * the four rule shapes were searched nowhere. The hole that matters is not the + * rule's arithmetic — the cases above cover that over hand-built layouts — but + * the thread: `solveIsland` resolves its own context, and if the anomaly stopped + * reaching `buildIslandContext` every solve under that rule would come back with + * a layout rated under the base rules and a power figure to match, with only the + * app's own scorer disagreeing. + * + * The assertions are properties rather than figures, because a stage deadline is + * wall-clock and a searched power is not reproducible on CI: the layout the + * search reports must score the same when rebuilt from the roster and rated for + * its tiles, and must sit under the anomaly-aware bound. + */ + const BOARD = ["GGGGGG", "GGGGGG", "GGGGGG"]; + + /** + * Searches `island` under `anomaly` and rebuilds what it returned the way the + * app does — every building resolved from the roster and rated for the tile it + * landed on — so the two powers can be compared. + */ + const searchAndRebuild = async ( + island: IslandSubGrid, + roster: EffectiveBuilding[], + anomaly: AnomalyDefinition, + rngSeed: number, + ) => { + const ctx = buildIslandContext( + island.grid, + island.buildable, + anomaly, + island.waterAdjacent, + ); + const byId = new Map(roster.map((b) => [b.id, b])); + const tileAt = new Map(); + for (let t = 0; t < ctx.n; t++) tileAt.set(`${ctx.xs[t]},${ctx.ys[t]}`, t); + + const solution = await solveIsland( + island, + roster, + 0.4, + undefined, + rngSeed, + anomaly, + ); + const rebuilt: Placement = new Array(ctx.n).fill(null); + for (const p of solution.placements) { + const tile = tileAt.get(`${p.x},${p.y}`)!; + rebuilt[tile] = ctx.rate(tile, byId.get(p.buildingId)!); + } + + // The same rebuilt layout scored under the base rules, so each case can say + // the rule it names actually changes this board — a property test on a board + // where the anomaly happens to be a no-op proves nothing. + const plain = buildIslandContext( + island.grid, + island.buildable, + undefined, + island.waterAdjacent, + ); + const unrated: Placement = new Array(plain.n).fill(null); + for (const p of solution.placements) { + const tile = tileAt.get(`${p.x},${p.y}`)!; + unrated[tile] = byId.get(p.buildingId)!; + } + + return { + reported: solution.powerOutput, + rebuilt: simulateIsland(rebuilt, ctx).totalPower, + underBaseRules: simulateIsland(unrated, plain).totalPower, + placed: solution.placements.length, + bound: estimateTotalMaxPower([island], roster, anomaly), + }; + }; + + it("threads a shared cooling pool into the layout it reports", async () => { + /* + * Pooling changes both halves of what a solve is worth: every cooler is + * x0.88 and adjacency stops mattering. Drop the anomaly on the way into the + * context and the search reports a figure about 13.6% over what the board is + * actually worth, arranged for a rule it is not under. + * + * The board is handed over whole, as `wholeBoardIsland` does — that is what a + * pool is defined over. + */ + const cryo = getAnomaly("cryo_nexus"); + // A cooler tight against the generators' waste, so the x0.88 genuinely + // binds and the pool has work to do. + const roster = basicRoster({ reactorValue: 500, coolerValue: 30 }); + const [island] = splitGridIntoIslands( + makeGrid(BOARD), + canCoolDirectProducer(roster), + cryo, + ); + + const out = await searchAndRebuild(island, roster, cryo, 4321); + + expect(out.placed).toBeGreaterThan(0); + expect(out.reported).toBeGreaterThan(0); + expect(out.reported).toBeCloseTo(out.rebuilt, 6); + expect(out.reported).toBeLessThanOrEqual(out.bound + EPS); + expect( + out.reported, + "the rule has to change this board, or the round trip is vacuous", + ).not.toBeCloseTo(out.underBaseRules, 6); + }); + + it("threads role isolation into the layout it reports", async () => { + /* + * The rule the search has to act on rather than merely be rated by: a + * generator's multiplier is a function of what its neighbours are, so the + * search's job under it is mostly to keep generators apart. The roster is + * generator-bound on purpose, so an isolated generator is genuinely worth + * more and the rule is not power-neutral here. + */ + const singularity = getAnomaly("singularity_isolation"); + const roster = basicRoster({ + reactorValue: 500, + generatorValue: 40, + coolerValue: 200, + }); + const [island] = splitGridIntoIslands( + makeGrid(BOARD), + canCoolDirectProducer(roster), + singularity, + ); + + const out = await searchAndRebuild(island, roster, singularity, 4321); + + expect(out.placed).toBeGreaterThan(0); + expect(out.reported).toBeGreaterThan(0); + expect(out.reported).toBeCloseTo(out.rebuilt, 6); + expect(out.reported).toBeLessThanOrEqual(out.bound + EPS); + expect( + out.reported, + "the rule has to change this board, or the round trip is vacuous", + ).not.toBeCloseTo(out.underBaseRules, 6); + }); + + it("searches a roster the Time Lab has already scaled", async () => { + /* + * Research reaches the search only through the roster — + * `getEffectiveBuildings` folds it in and nothing below that knows a Time Lab + * exists — so a solve on a researched roster is a path nothing else here + * walks. It is also the one place both multipliers meet on a board: Infinite + * Grid at x5 on the generator and Absolute Zero at x5 on the cooler, and then + * a shore bonus on top of both. + */ + const tidal = getAnomaly("tidal_ascendancy"); + const { buildings, unlocks } = basicCatalogue(); + const roster = getEffectiveBuildings( + buildings, + unlocks, + prestigeScales({ infinite_grid: 4, absolute_zero: 4 }), + ); + expect( + roster.map((b) => b.effectiveValue), + "the reactor is untouched; the other two are x5", + ).toEqual([100, 500, 500]); + + const [island] = splitGridIntoIslands( + makeGrid(BOARD), + canCoolDirectProducer(roster), + tidal, + ); + const out = await searchAndRebuild(island, roster, tidal, 4321); + + expect(out.placed).toBeGreaterThan(0); + expect(out.reported).toBeGreaterThan(0); + expect(out.reported).toBeCloseTo(out.rebuilt, 6); + expect(out.reported).toBeLessThanOrEqual(out.bound + EPS); + expect( + out.reported, + "the rule has to change this board, or the round trip is vacuous", + ).not.toBeCloseTo(out.underBaseRules, 6); + }); + + it("rates a researched roster research-first, then the anomaly", () => { + /* + * The order the two arrive in, end to end and in the last bit. The game + * applies the Time Lab in the ScriptableObject getter and the anomaly in the + * runtime getter, which is the order these two layers happen to be in — + * `getEffectiveBuildings` folds research into the roster and `ctx.rate` + * scales what comes out — and it is not the same double as the other way + * round: generator7's fourth tier under Infinite Grid maxed and then a Tidal + * shore is 7.38975e22, against 7.389749999999999e22 reversed. + * + * `scaling.test.ts` pins the arithmetic; this pins that the production path + * is that way round, which no test reached — `prestige.test.ts` never builds + * a context, and every context test starts from an unresearched roster. + */ + const tidal = getAnomaly("tidal_ascendancy"); + const [researched] = getEffectiveBuildings( + BUILDINGS, + { generator7: 3 }, + prestigeScales({ infinite_grid: 4 }), + ); + expect(researched.effectiveValue).toBe(8.85e21 * 5); + expect(researched.baseValue, "the tier it identifies as").toBe(8.85e21); + + // Bare grass, so every tile of it is on the board's edge and therefore + // shore. + const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, tidal); + const onShore = ctx.rate(ctx.tiles[0], researched); + + expect(onShore.effectiveValue).toBe(8.85e21 * 5 * 1.67); + expect(onShore.effectiveValue).toBe(7.38975e22); + // The anomaly first and the research second, which is the wrong way round. + expect(onShore.effectiveValue).not.toBe(8.85e21 * 1.67 * 5); + expect(onShore.baseValue).toBe(8.85e21); + }); +}); diff --git a/packages/solver/tests/blueprint.test.ts b/packages/solver/tests/blueprint.test.ts index 8a72485..8501683 100755 --- a/packages/solver/tests/blueprint.test.ts +++ b/packages/solver/tests/blueprint.test.ts @@ -20,7 +20,9 @@ */ import { deflateSync, inflateSync } from "node:zlib"; import { describe, expect, it } from "vitest"; +import { ANOMALIES } from "../src/data/anomalies"; import { BUILDINGS } from "../src/data/buildings"; +import { PRESTIGE_UPGRADES } from "../src/data/prestige"; import { makeGrid } from "../src/grid"; import { BLUEPRINT_VERSION, @@ -422,6 +424,82 @@ describe("the rules section", () => { ); expect(blueprintKey(GRID, [])).toBe(blueprintKey(GRID, [])); }); + + /* + * The two byte tables against the two tables they are parallel to, the same + * way every shipped building is held to a round-trip above. + * + * The maps are keyed on `AnomalyId` and `PrestigeUpgradeId`, so a shipped id + * with no byte no longer compiles — but a compile-time key check says nothing + * about whether the byte written comes back as the id that was put in, which + * is the property a share code actually rests on. A transposed pair of bytes + * type-checks perfectly and decodes as the other anomaly. + */ + it.each(ANOMALIES.map((a) => a.id))( + "round-trips %s, so no shipped anomaly is missing a byte", + async (anomalyId) => { + const decoded = await decodeBlueprint( + await encodeBlueprint(GRID, [], {}, blueprintRules(anomalyId, {})), + ); + + expect(decoded.rules?.anomalyId).toBe(anomalyId); + }, + ); + + it.each(PRESTIGE_UPGRADES.map((u) => u.id))( + "round-trips %s, so no shipped research is missing a byte", + async (id) => { + const decoded = await decodeBlueprint( + await encodeBlueprint( + GRID, + [], + {}, + // A non-zero level, because a level is an index and 0 is a real one: + // an entry dropped on the way out would still leave `research` empty + // whatever level it carried, and this is the pair that must survive. + blueprintRules("none", { [id]: 3 }), + ), + ); + + expect(decoded.rules?.research).toEqual({ [id]: 3 }); + }, + ); + + it("drops a research id the byte table has no entry for", async () => { + // The compatibility rule the encoder applies in the one direction it can: + // a reader from a later build names a research this one cannot spell, and + // the rest of the section still crosses. + const decoded = await decodeBlueprint( + await encodeBlueprint( + GRID, + [], + {}, + blueprintRules("none", { not_a_research: 2, absolute_zero: 1 }), + ), + ); + + expect(decoded.rules?.research).toEqual({ absolute_zero: 1 }); + }); + + it("writes an unknown anomaly id as no anomaly", async () => { + // `rules.anomalyId` is a plain string — it arrives off `localStorage` and + // across the worker boundary — so this is the one miss the closed key type + // cannot catch. It is the same total reading `getAnomaly` makes, and it must + // not take the research with it. + const decoded = await decodeBlueprint( + await encodeBlueprint( + GRID, + [], + {}, + blueprintRules("not_an_anomaly", { stellar_forge: 0 }), + ), + ); + + expect(decoded.rules).toEqual({ + anomalyId: "none", + research: { stellar_forge: 0 }, + }); + }); }); describe("the format version byte", () => { diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index c6d0557..faa5cdd 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -16,6 +16,8 @@ import { EPS } from "../src/solver/constants"; import { buildIslandContext } from "../src/solver/context"; import { makeGrid } from "../src/grid"; import { getAnomaly } from "../src/data/anomalies"; +import { ISLAND_TEMPLATES } from "../src/data/maps"; +import { decodeBlueprint } from "../src/encoding/blueprint"; import { canCoolDirectProducer, computeWaterAdjacency, @@ -30,6 +32,7 @@ import type { EffectiveBuilding, IslandSubGrid, Placement, + TerrainAffinityAnomaly, } from "../src/solver/types"; import { basicRoster, @@ -37,6 +40,7 @@ import { directProducer, expectClose, generator, + placeOn, reactor, } from "./helpers"; @@ -224,7 +228,9 @@ describe("water adjacency", () => { it("flags every tile on the board's edge", () => { const at = flagsOf(["GGGGG", "GGGGG", "GGGGG"]); - // The one tile with eight in-board, non-water neighbours. + // The middle row's interior: eight in-board neighbours, none of them water. + // (1, 1) and (3, 1) qualify too on a board this wide; one is enough to show + // the flag is not simply set everywhere. expect(at(2, 1)).toBe(0); for (const [x, y] of [ [0, 0], @@ -281,6 +287,97 @@ describe("water adjacency", () => { expect(island.waterAdjacent.length).toBe(island.buildable.length); expect(island.waterAdjacent.length).toBe(island.width * island.height); }); + + it("translates the mask by the window's own origin", () => { + /* + * The mask is copied out of the full-grid pass at `origY * originalWidth + + * origX`, and forgetting either half of that origin is the one natural + * mistake in the loop. Every case above has a window whose pad origin is + * (0, 0) — clamped against the board on both axes — so the offset cancels + * and a mis-indexed read is the right answer by accident. + * + * This component is inset from the left and the top, so `padMinX` is 2 and + * `padMinY` is 1, and the only water on the board sits on one side of it. + * The mask is therefore asymmetric: read at the wrong origin it describes a + * different neighbourhood, and under a terrain bonus that is the wrong tiles + * rated x1.67 with nothing failing. + */ + const rows = [ + "RRRRRRRR", + "RRRRRRRR", + "RR.GGGRR", + "RRRGGGRR", + "RRRGGGRR", + "RRRRRRRR", + "RRRRRRRR", + ]; + const grid = makeGrid(rows); + const [island] = splitGridIntoIslands(grid, false); + const full = computeWaterAdjacency(grid); + const originalWidth = grid[0].length; + + // The window: the component's box (x 3-5, y 2-4) padded a tile on every + // side, so its origin is genuinely off the board's. + expect([island.width, island.height]).toEqual([5, 5]); + expect(island.originalTileIndices[0]).toBe(1 * originalWidth + 2); + + // Read through the window's own remap, which is the translation spelled out + // a second and independent way. + for (let localY = 0; localY < island.height; localY++) { + for (let localX = 0; localX < island.width; localX++) { + const localFlat = localY * island.width + localX; + expect( + island.waterAdjacent[localFlat], + `local (${localX},${localY})`, + ).toBe(full[island.originalTileIndices[localFlat]]); + } + } + + // And as literal values, so the two spellings cannot be wrong together. + // Only the water at (2, 2) and the board's own edges flag anything, and + // neither reaches this component's interior: local (1,1) and (1,2) are the + // grass beside the water, and the rest of the island is inland. + const at = (x: number, y: number) => + island.waterAdjacent[y * island.width + x]; + expect([at(1, 1), at(1, 2)], "the grass beside the water").toEqual([1, 1]); + expect( + [at(2, 1), at(3, 1), at(2, 2), at(1, 3), at(3, 3)], + "every other tile of the island is inland", + ).toEqual([0, 0, 0, 0, 0]); + }); + + it("carries the right mask on every island of every shipped map", async () => { + /* + * Where the real divergence lives. Every one of the eight shipped boards has + * islands whose window is off the origin, and a mis-indexed copy was measured + * against the correct mask at between 28 and 166 mismatched tiles per map — + * which under Tidal Ascendancy is that many tiles rated at the wrong + * multiplier, on the boards players actually solve. The fixtures pass no + * anomaly, so nothing else in the suite can see it. + */ + let offOriginWindows = 0; + + for (const template of ISLAND_TEMPLATES) { + const { grid } = await decodeBlueprint(template.code); + const originalWidth = grid[0].length; + const full = computeWaterAdjacency(grid); + + for (const island of splitGridIntoIslands(grid, false)) { + const origin = island.originalTileIndices[0]; + if (origin % originalWidth > 0 || origin >= originalWidth) + offOriginWindows++; + + for (let i = 0; i < island.waterAdjacent.length; i++) { + expect( + island.waterAdjacent[i], + `${template.id}, window tile ${i}`, + ).toBe(full[island.originalTileIndices[i]]); + } + } + } + + expect(offOriginWindows, "the case this is here for").toBeGreaterThan(0); + }); }); describe("coordinate remapping", () => { @@ -468,6 +565,104 @@ describe("the theoretical max-power bound", () => { } }); + it("is not beaten by a deliberately isolated generator layout", () => { + /* + * The case a random-layout test cannot find, and the one that shipped + * broken. `role_isolation` scales an isolated generator's heat *intake* by + * 2.5, so wherever generator intake is the short side of + * `min(nReact * rVal, nGen * gVal)` a layout that keeps its generators apart + * absorbs up to 2.5x the heat the bound allowed. Random placement almost + * never isolates four generators, so this layout is built by hand: + * + * G R G four generators on the corners, which on a 3x3 are + * R C R mutually non-adjacent (Chebyshev 2 apart), so every one of + * G R G them rates `isolated`; one cooler in the middle reaches all + * four, and each generator touches two reactors. + * + * The roster is generator-bound on purpose — 100 of reactor heat against 20 + * of generator intake — which is an ordinary mid-game partial roster rather + * than a contrivance: the full catalogue's top reactor has 3.9x the headroom + * over its top generator, so the shipped maps were safe only by accident. + */ + const singularity = getAnomaly("singularity_isolation"); + const roster = [reactor(100), generator(20), cooler(100)]; + const island = islandFor(["GGG", "GGG", "GGG"], roster); + const ctx = buildIslandContext( + island.grid, + island.buildable, + singularity, + island.waterAdjacent, + ); + const placement = placeOn( + ctx, + ["GRG", "RCR", "GRG"], + { G: roster[1], R: roster[0], C: roster[2] }, + ); + + const { totalPower } = simulateIsland(placement, ctx); + + // 4 generators x (20 x 2.5) of intake against 400 of reactor heat, at 0.75 + // energy, with 50 of waste against 100 of cooling. + expectClose(totalPower, 150); + // The bound that ignored the anomaly: 8 engine tiles + 1 cooler, capped at + // 2 reactors feeding 6 generators, so 120 of heat and 90 of power. + expectClose(boundFor(island, roster), 90); + expect( + totalPower, + "the layout beats the bound computed on the plain roster", + ).toBeGreaterThan(boundFor(island, roster)); + // Which is the whole point: the bound has to allow the isolated rating. + expect(totalPower).toBeLessThanOrEqual( + estimateTotalMaxPower([island], roster, singularity) + EPS, + ); + }); + + it("is never beaten by a random layout under a shared cooling pool", () => { + /* + * Pooling is the rule most likely to walk past a bound that assumed + * adjacency — a cooler on the far side of the board now cools everything — + * and the bound is safe because it never assumed any: it relaxes adjacency + * away and asks only what the tile counts allow. The 0.88 on every cooler + * only ever costs cooling, so the island scale stays 1 and the bound is the + * plain one. + * + * The board is handed over whole here, as `wholeBoardIsland` does, because + * that is what the pool is defined over. + */ + const cryo = getAnomaly("cryo_nexus"); + const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); + const [island] = splitGridIntoIslands( + makeGrid(["GGG", "GGG", "GGG"]), + canCoolDirectProducer(roster), + cryo, + ); + const bound = estimateTotalMaxPower([island], roster, cryo); + const ctx = buildIslandContext( + island.grid, + island.buildable, + cryo, + island.waterAdjacent, + ); + + expectClose(bound, boundFor(island, roster)); + + const rng = new Rng(20260921); + const options: (EffectiveBuilding | null)[] = [...roster, null]; + for (let attempt = 0; attempt < 300; attempt++) { + const placement: Placement = Array.from({ length: ctx.n }, (_, t) => { + const pick = rng.choice(options); + return pick === null ? null : ctx.rate(t, pick); + }); + + const { totalPower } = simulateIsland(placement, ctx); + + expect( + totalPower, + `a layout beat the 'upper' bound on attempt ${attempt}`, + ).toBeLessThanOrEqual(bound + EPS); + } + }); + it("rises with a terrain bonus only where a tile qualifies", () => { const tidal = getAnomaly("tidal_ascendancy"); const roster = basicRoster(); @@ -491,6 +686,41 @@ describe("the theoretical max-power bound", () => { ); }); + it("errs high for a terrain list the shore mask cannot decide", () => { + /* + * The shore mask is the only thing that knows off-board counts as water, so + * the water-adjacency scan answers a `["water"]` anomaly exactly — and + * nothing else. `terrainScales` qualifies a tile on *any* listed terrain, so + * a hand-added anomaly naming rock as well would rate a landlocked island's + * tiles at the multiplier while a bound that scanned only for water stayed + * at 1, and be beatable again with nothing on screen saying so. + * + * `ANOMALIES` is hand-owned and documented as expected to grow, so the rule + * is tested against a definition of the shape a new entry could take rather + * than against a shipped one. + */ + const mixed: TerrainAffinityAnomaly = { + // Tidal's own identity, because `AnomalyId` is a closed union and this is + // a shape a future entry could take rather than one that ships. Only the + // terrain list is the point. + ...(getAnomaly("tidal_ascendancy") as TerrainAffinityAnomaly), + terrain: ["water", "rock"], + multiplier: 2, + }; + const roster = basicRoster(); + // Walled in by rock and well away from the board's edge: no water anywhere + // near it, and every tile of it beside rock. + const inland = islandFor( + ["RRRRR", "RGGGR", "RGGGR", "RGGGR", "RRRRR"], + roster, + ); + + expectClose( + estimateTotalMaxPower([inland], roster, mixed), + boundFor(inland, roster) * 2, + ); + }); + it("totals as the sum over islands", () => { const roster = basicRoster(); const islands = splitGridIntoIslands( diff --git a/packages/solver/tests/placementSearch.rating.test.ts b/packages/solver/tests/placementSearch.rating.test.ts new file mode 100644 index 0000000..b9ffc8b --- /dev/null +++ b/packages/solver/tests/placementSearch.rating.test.ts @@ -0,0 +1,427 @@ +/** + * The search in a tile's own units. + * + * A placement holds RATED buildings — what a tile is worth under the anomaly in + * force — while the roster, the candidate lists and the composition targets are + * all the plain figures the player owns. Every case here is a place where the two + * met and the comparison was made in the wrong one, and every one of them failed + * silently: no number moved, nothing threw, and the only symptom was a search + * that quietly did less than it says it does. + * + * They are gathered in one file because they are one mistake made four times, and + * because none of them is visible at baseline: `ctx.rate` is the identity there + * and `baseValue === effectiveValue`, so a test written without an anomaly passes + * under either reading. + * + * `simulateIsland` is mocked as a pass-through spy so one case can count + * evaluations. It calls the real implementation, so no number in this file comes + * from the mock. + */ +import { describe, expect, it, vi } from "vitest"; +import { getAnomaly } from "../src/data/anomalies"; +import { buildIslandContext, type IslandContext } from "../src/solver/context"; +import { EPS } from "../src/solver/constants"; +import { makeGrid } from "../src/grid"; +import { Pacer } from "../src/solver/pacer"; +import { downgradeOversized, internals } from "../src/solver/placementSearch"; +import { simulateIsland } from "../src/solver/simulate"; +import { generator, reactor, cooler, basicRoster } from "./helpers"; +import type { EffectiveBuilding, Placement } from "../src/solver/types"; + +vi.mock("../src/solver/simulate", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, simulateIsland: vi.fn(real.simulateIsland) }; +}); + +const tidal = getAnomaly("tidal_ascendancy"); +const singularity = getAnomaly("singularity_isolation"); + +/** + * Two hubs' worth of grass, one on the board's edge and one walled in by rock, + * so the island is deliberately NOT uniformly rated: the top row is shore (off + * the board is water) and the bottom row is inland. + */ +const MIXED_SHORE = ["GGGRR", "RRRRR", "RRRRR", "RGGGR", "RRRRR"]; + +function tileAt(ctx: IslandContext, x: number, y: number): number { + for (let t = 0; t < ctx.n; t++) { + if (ctx.xs[t] === x && ctx.ys[t] === y) return t; + } + throw new Error(`no buildable tile at ${x},${y}`); +} + +/** Places `spec` — `[x, y, building]` in board coordinates — rated for its tile. */ +function ratedPlacement( + ctx: IslandContext, + spec: readonly [number, number, EffectiveBuilding][], +): Placement { + const placement: Placement = new Array(ctx.n).fill( + null, + ); + for (const [x, y, building] of spec) { + const tile = tileAt(ctx, x, y); + placement[tile] = ctx.rate(tile, building); + } + return placement; +} + +describe("a report row's two capacity figures", () => { + it("states the rating the tile ran at beside the authored tier", () => { + /* + * `baseValue` identifies the tier and must stay authored — a scaled one + * resolves to a HIGHER tier and gets scaled a second time. So the figure the + * row's own numbers were measured against has to be carried separately, and + * the walk's "under-fed reactor" move is what needs it: `heatProduced` is + * what the distribution sent, capped by the tile's RATING. + * + * A shore reactor rated 167 feeding a generator that can only take 100.2 is + * under-fed by a third — and sits ABOVE its authored 100, so the move's + * predicate read against `baseValue` never fires. Under a x5 research the + * same reactor has to fall below 20% fill before the walk notices it. + */ + const [R, G, C] = basicRoster({ + reactorValue: 100, + generatorValue: 60, + coolerValue: 25, + }); + const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, tidal); + const rows = simulateIsland( + ratedPlacement(ctx, [ + [0, 0, R], + [1, 0, G], + [2, 0, C], + ]), + ctx, + ).placements; + + const reactorRow = rows.find((r) => r.buildingId === R.id)!; + expect(reactorRow.baseValue).toBe(100); + expect(reactorRow.ratedValue).toBe(167); + expect(reactorRow.heatProduced).toBeCloseTo(100.2, 9); + + // The move fires on the rating... + expect(internals.isUnderFed(reactorRow)).toBe(true); + // ...and would not have on the authored value, which is the whole bug: this + // reactor is a third short of what its tile can carry and still above the + // tier it was authored at. + expect(reactorRow.heatProduced).toBeGreaterThan(reactorRow.baseValue); + }); + + it("states the same figure twice where nothing is scaled", () => { + const [R, G, C] = basicRoster({ coolerValue: 25 }); + const ctx = buildIslandContext(makeGrid(["GGG"])); + for (const row of simulateIsland( + ratedPlacement(ctx, [ + [0, 0, R], + [1, 0, G], + [2, 0, C], + ]), + ctx, + ).placements) { + expect(row.ratedValue, row.buildingId).toBe(row.baseValue); + } + }); +}); + +describe("the island's rating ceiling", () => { + /* + * What makes a plain-roster ceiling comparable to a rated layout's power. It is + * one number for the island, the same move `estimateTotalMaxPower` makes to + * keep its bound a bound. + */ + const { islandRatingCeiling, targetCompositions } = internals; + const probes = basicRoster({ coolerValue: 25 }); + + it("is exactly one under the rules that leave the roster alone", () => { + for (const anomaly of [getAnomaly("none"), getAnomaly("cryo_nexus")]) { + const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, anomaly); + // Cryo's 0.88 only ever costs cooling, so it cannot lift a ceiling. + expect(islandRatingCeiling(ctx, probes), anomaly.id).toBe(1); + } + }); + + it("is the terrain multiplier where any tile of the island qualifies", () => { + expect( + islandRatingCeiling( + buildIslandContext(makeGrid(MIXED_SHORE), undefined, tidal), + probes, + ), + ).toBeCloseTo(1.67, 9); + // ...and one where none of it does: walled in, well away from the board. + expect( + islandRatingCeiling( + buildIslandContext( + makeGrid(["RRRRR", "RGGGR", "RRRRR"]), + undefined, + tidal, + ), + probes, + ), + ).toBe(1); + }); + + it("allows both variants of a layout-resolved rule", () => { + // Which one a tile gets is a function of the layout rather than the island, + // and a search free to keep generators apart rates every one at the bonus. + const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, singularity); + expect(islandRatingCeiling(ctx, probes)).toBeCloseTo(2.5, 9); + }); + + it("is what lets the composition retarget run on a rated layout at all", () => { + /* + * The regression, stated as the decision rather than as a power figure, so + * it does not depend on a budget. + * + * A target is scored from the plain roster while `bestPower` is a rated + * layout's. On this island the best 6-tile composition scores 150 unscaled, + * and two hubs — one on the shore, one inland — are already worth 200.25. The + * gate is a `break`, so read as-is the FIRST target ends the stage and every + * arrangement it would have tried is lost: the one stage that exists to cover + * seeding's blind spot, gone under the anomaly that most needs it. + * + * Scaled by the ceiling the same target reads 250.5, which is above the + * layout in hand, and the stage runs. + */ + const roster = basicRoster({ coolerValue: 25 }); + const [R, G, C] = roster; + const ctx = buildIslandContext(makeGrid(MIXED_SHORE), undefined, tidal); + const power = simulateIsland( + ratedPlacement(ctx, [ + [0, 0, R], + [1, 0, G], + [2, 0, C], + [1, 3, R], + [2, 3, G], + [3, 3, C], + ]), + ctx, + ).totalPower; + const top = targetCompositions(ctx.n, [R], [G], [C])[0]; + const ceiling = islandRatingCeiling(ctx, roster); + + expect(power).toBeCloseTo(200.25, 9); + expect(top.power).toBeCloseTo(150, 9); + // As scored, the target is behind a layout the search has already reached, so + // read in the roster's units the stage breaks out on its first entry... + expect(top.power).toBeLessThan(power + EPS); + expect(internals.targetCanBeat(top, power, 1)).toBe(false); + // ...and in the layout's units it is ahead of it, so the stage runs. + expect(internals.targetCanBeat(top, power, ceiling)).toBe(true); + }); +}); + +describe("a move that would write a tile what it already holds", () => { + it("is skipped on a rated tile, not simulated", async () => { + /* + * `greedyPolish` is the deterministic one of the six sites, so it is where + * this is counted. Its candidate list is the plain roster and the tiles hold + * rated buildings, so `building === held` is never true on a scaled tile: the + * sweep writes back an identical object and pays a full `simulateIsland` for + * it, once per tile per sweep, in a stage whose only product is steps per + * second. + * + * The layout is one hub on an all-shore island, already the best arrangement + * of it, so exactly one sweep runs and the arithmetic is exact: one + * evaluation on entry, then one per (tile, candidate) pair that is not the + * building the tile is already running — 3 x (4 - 1). Without the fix it is + * 3 x 4. + */ + const [R, G, C] = basicRoster({ coolerValue: 25 }); + const candidates: (EffectiveBuilding | null)[] = [R, G, C, null]; + const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, tidal); + const placement = ratedPlacement(ctx, [ + [0, 0, R], + [1, 0, G], + [2, 0, C], + ]); + + const spy = vi.mocked(simulateIsland); + spy.mockClear(); + const polished = await internals.greedyPolish( + placement, + ctx, + candidates, + performance.now() + 5_000, + new Pacer(), + ); + + expect(polished.power).toBeCloseTo(125.25, 9); + expect(spy.mock.calls.length).toBe(1 + ctx.n * (candidates.length - 1)); + }); + + it("is still skipped where nothing is rated", async () => { + // The same count at baseline, where `held` IS the roster entry: the guard has + // to be the old one plus the rating, never a different one. + const [R, G, C] = basicRoster({ coolerValue: 25 }); + const candidates: (EffectiveBuilding | null)[] = [R, G, C, null]; + const ctx = buildIslandContext(makeGrid(["GGG"])); + const placement = ratedPlacement(ctx, [ + [0, 0, R], + [1, 0, G], + [2, 0, C], + ]); + + const spy = vi.mocked(simulateIsland); + spy.mockClear(); + await internals.greedyPolish( + placement, + ctx, + candidates, + performance.now() + 5_000, + new Pacer(), + ); + + expect(spy.mock.calls.length).toBe(1 + ctx.n * (candidates.length - 1)); + }); +}); + +describe("right-sizing a layout whose ratings come from its own shape", () => { + /* + * `role_isolation` is the one rule `ctx.rate` cannot answer: a generator's + * multiplier is a function of what its neighbours ARE, so `simulateIsland` + * resolves it per layout. Right-sizing measures a load that came off one of + * those rows against a candidate from the plain roster, and the two are three + * halves apart. + */ + const REACTOR = reactor(300); + const GEN_S = generator(120, "gen_s"); + const GEN_L = generator(320, "gen_l"); + const COOLER = cooler(200); + const roster = [REACTOR, GEN_S, GEN_L, COOLER]; + + it("hands back the capacity an isolated generator never uses", () => { + /* + * A lone generator is rated x2.5, so the authored 320 tile is running at 800 + * and absorbing 300 of it. The smallest tier that covers that load is the + * authored 120 — rated 300, exactly enough — and at the same power, because a + * generator's energy scales with how full it is: 0.75 x 800 x 0.375 is 0.75 x + * 300 x 1. Compared in the roster's units instead, 120 does not cover 300 and + * the pass leaves 500 of intake the player paid for and nothing uses. + * + * The worked example one step up is NOT a downgrade and must not become one: + * the same tile absorbing 700 keeps the 320, since 120 rated 300 cannot carry + * it. + */ + const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, singularity); + const placement = ratedPlacement(ctx, [ + [0, 0, REACTOR], + [1, 0, GEN_L], + [2, 0, COOLER], + ]); + const before = simulateIsland(placement, ctx); + const generatorRow = before.placements.find( + (r) => r.buildingId === GEN_L.id, + )!; + expect(generatorRow.ratedValue).toBeCloseTo(800, 9); + expect(generatorRow.heatConsumed).toBeCloseTo(300, 9); + + const after = downgradeOversized( + before.placements, + before.totalPower, + roster, + ctx, + ); + + expect( + after.rows.find((r) => r.x === 1)!.buildingId, + "the isolated generator kept a tier it runs at 37.5% of", + ).toBe(GEN_S.id); + // The pass re-tiers and nothing else: same power, same occupied tiles. + expect(after.power).toBeCloseTo(before.totalPower, 9); + expect(after.rows.length).toBe(before.placements.length); + }); + + it("leaves a crowded generator a tier that carries its penalised load", () => { + /* + * The other direction. Two generators touching are rated x0.8, so a candidate + * that covers the load in the roster's units may not cover it at all once + * rated — and this pass is not allowed to cost power. The guard is the + * re-simulation, which is why nothing was ever WRONG here; the assertion is + * the property the pass sells, stated in the units the layout is rated in. + */ + const ctx = buildIslandContext( + makeGrid(["GGGG", "GGGG"]), + undefined, + singularity, + ); + const placement = ratedPlacement(ctx, [ + [0, 0, REACTOR], + [1, 0, GEN_L], + [2, 0, GEN_L], + [3, 0, REACTOR], + [1, 1, COOLER], + [2, 1, COOLER], + ]); + const before = simulateIsland(placement, ctx); + const after = downgradeOversized( + before.placements, + before.totalPower, + roster, + ctx, + ); + + expect(after.power).toBeGreaterThanOrEqual(before.totalPower - EPS); + const byId = new Map(roster.map((b) => [b.id, b])); + for (const row of after.rows) { + expect( + row.ratedValue, + `${row.buildingId} at (${row.x},${row.y}) cannot carry its own load`, + ).toBeGreaterThanOrEqual( + internals.tileLoad(byId.get(row.buildingId)!, row) - 1e-9, + ); + } + }); +}); + +describe("every building a seed writes", () => { + it("is already rated for the tile it landed on", () => { + /* + * The deterministic guard for the class of bug that had to be fixed by hand + * once: a write that went straight to the placement array instead of through + * `put()`, leaving a roster entry on a tile that rates above it. + * + * Stated over the seed rather than over a finished layout on purpose. The + * round-trip test in `anomalies.test.ts` can only fail if an unrated write + * survives seeding, repair, annealing, pruning and right-sizing into the + * layout that is returned — so it depends on the budget, on the roster's + * shape (a multi-tier roster hands `downgradeOversized` the chance to rewrite + * the tile) and on which stages happened to run. This depends on none of + * them: `ctx.rate` is idempotent, so a correctly rated tile is a fixed point + * of it and an unrated one is not. + * + * The island is deliberately mixed — a shore hub and an inland one — so a + * seed that scaled every tile by the same factor, or none, fails too. + */ + const roster = basicRoster({ coolerValue: 25, dpValue: 120 }); + const [R, G, C, D] = roster; + const ctx = buildIslandContext(makeGrid(MIXED_SHORE), undefined, tidal); + expect(ctx.uniformRating, "the case this is here for").toBe(false); + + const seed = internals.constructMultiStartSeed( + ctx, + [R], + [G], + [C], + [D], + performance.now() + 2_000, + ); + + let onScaledTiles = 0; + for (let t = 0; t < ctx.n; t++) { + const building = seed[t]; + if (building === null) continue; + // A tile whose rating is the identity cannot tell a rated write from an + // unrated one, so count the ones that can. + if (ctx.rate(t, R) !== R) onScaledTiles++; + expect( + ctx.rate(t, building), + `${building.id} at (${ctx.xs[t]},${ctx.ys[t]}) is not rated for its tile`, + ).toBe(building); + } + + expect( + onScaledTiles, + "a seed that filled only the inland hub would pass vacuously", + ).toBeGreaterThan(0); + }); +}); diff --git a/packages/solver/tests/scaling.test.ts b/packages/solver/tests/scaling.test.ts index e2f676f..1d8ebce 100644 --- a/packages/solver/tests/scaling.test.ts +++ b/packages/solver/tests/scaling.test.ts @@ -121,14 +121,58 @@ describe("two multipliers compose as two calls, not as one product", () => { expect(both.effectiveValue).not.toBe(1.11e21 * (5 * 2.5)); }); - it("re-derives waste at each step, so the last one wins", () => { - // Waste after two calls is the snapped difference of the twice-scaled pair, - // never the once-derived waste carried through a second multiply. - const both = scaleEffectiveBuilding(scaleEffectiveBuilding(atTier(1), 5), 1.67); + it("applies them in that order, research first, anomaly second", () => { + // Which of the two calls comes first is itself observable, and the factors + // above cannot see it: 5 x 2.5 and 2.5 x 5 land on the same double. The + // game's order is the Time Lab in the SO getter and the anomaly in the + // runtime getter — research first — and on generator7's fourth tier under + // Infinite Grid maxed (x5) then a Tidal shore (x1.67) the two orders differ + // in the last bit: 7.38975e22 against 7.389749999999999e22. + // + // Nothing above this line in the stack would notice: the roster resolves + // research into its three figures and the context rates the result, so + // reversing the two produces a board rated a few ULPs off — and the golden + // fixtures pass no anomaly, so they cannot see it either. + const researchFirst = scaleEffectiveBuilding( + scaleEffectiveBuilding(atTier(3), 5), + 1.67, + ); + const anomalyFirst = scaleEffectiveBuilding( + scaleEffectiveBuilding(atTier(3), 1.67), + 5, + ); + expect(researchFirst.effectiveValue).toBe(8.85e21 * 5 * 1.67); + expect(researchFirst.effectiveValue).toBe(7.38975e22); + expect(anomalyFirst.effectiveValue).toBe(7.389749999999999e22); + expect(researchFirst.effectiveValue).not.toBe(anomalyFirst.effectiveValue); + }); + + it("re-derives waste at each step, so the last one wins", () => { + /* + * Waste after two calls is the snapped difference of the twice-scaled pair, + * never the once-derived waste carried through a second multiply — and not + * the authored waste scaled twice either. + * + * The tier and the factors are chosen so the three readings actually part + * company, which most pairs do not: generator7's fourth tier (8.85e21 / + * 6.64e21) under Infinite Grid at level 4 (x4.5) and then a x0.8 penalty + * gives 7.95599999999999e21 derived against 7.956e21 either other way. The + * assertion against the identity alone is how `scaleEffectiveBuilding` + * computes waste, so it holds for any deriving implementation and cannot + * fail on its own. + */ + const once = scaleEffectiveBuilding(atTier(3), 4.5); + const both = scaleEffectiveBuilding(once, 0.8); + + expect(both.waste).toBe(7.95599999999999e21); expect(both.waste).toBe( snapToAuthoredPrecision(both.effectiveValue - both.energy), ); + // The once-derived waste carried through the second multiply... + expect(both.waste).not.toBe(snapToAuthoredPrecision(once.waste * 0.8)); + // ...and the authored waste scaled by both factors. Both are 7.956e21. + expect(both.waste).not.toBe(2.21e21 * 4.5 * 0.8); }); }); diff --git a/src/lib/components/inspector/BoardStatsCard.svelte b/src/lib/components/inspector/BoardStatsCard.svelte index 49a8281..b946baa 100755 --- a/src/lib/components/inspector/BoardStatsCard.svelte +++ b/src/lib/components/inspector/BoardStatsCard.svelte @@ -10,12 +10,13 @@ viewportState, } from "../../state"; import { - effectiveAtValue, + BUILDINGS, findBuilding, formatNumber, levelIndexForValue, } from "@reactor2/solver"; import { placementStatus } from "../../data/placements"; + import { ratedPlacementAt } from "../../simulation/simulator"; import { asset, formatDuration, @@ -260,10 +261,11 @@ * with no reactor beside it reads `0 / 17.7AC`, which says both that it does * nothing and how much it is missing. Bare figures could not tell those apart. * - * The ceilings come from `effectiveAtValue`, so they are the tier the - * building was *placed* at — the same one the scorer ran it at. Reading the - * player's current unlock level instead would re-rate a building the moment - * an upgrade is bought behind it, and the ratio would stop meaning anything. + * The ceilings come from the scorer, so they are the tier the building was + * *placed* at, rated for the tile it stands on — the same figures the row + * beside them was measured against. Reading the player's current unlock level + * instead would re-rate a building the moment an upgrade is bought behind it, + * and the ratio would stop meaning anything. * * Cooling is the one row measured against a live figure rather than a * ceiling: what a building needs is the waste it is actually making, which @@ -275,13 +277,38 @@ const def = findBuilding(building.buildingId); if (!def) return []; - // The scales the board on screen was scored under, which on a previewed - // board are the author's and not the reader's — see `placementPrestige`. - const max = effectiveAtValue( - def, - building.baseValue, + /* + * What this building was rated for on the board it stands on — the whole + * board, because a rule can rate a tile by what its neighbours are. + * + * Resolved by the scorer rather than from `effectiveAtValue` here, and that + * is the difference between a ceiling and a ceiling that is true. A + * placement carries only its *authored* tier value, which is right and must + * stay so — the tier is resolved back out of it — but the figures beside it + * were measured against what the tile rated that tier at. Against the plain + * roster a shore cooler printed `8.35AC / 8AC`, past a total it had already + * walked through, and a Cryo cooler at full tilt could never reach one. + * + * `boardPlacements` rather than `layoutState.placements`: the rows above are + * whichever board this panel describes, and the ceilings have to come off + * the same one. The rules are `layoutState`'s, which on a previewed board + * are the author's and not the reader's — see `placementPrestige`. + * + * Nothing to fall back to when the tile is not on the board: a building the + * scorer cannot place is one every figure beside it is zero for anyway, and + * a ceiling invented for it would be the only number on the card that was + * not measured. + */ + const max = ratedPlacementAt( + layoutState.grid, + BUILDINGS, + boardPlacements, + building.x, + building.y, layoutState.placementPrestige, + layoutState.placementAnomaly, ); + if (!max) return []; // A waste producer that is not getting the cooling it needs is shut down, // and this row is the only place the board admits it. The test is diff --git a/src/lib/simulation/simulator.test.ts b/src/lib/simulation/simulator.test.ts index 8e49b81..02693e2 100755 --- a/src/lib/simulation/simulator.test.ts +++ b/src/lib/simulation/simulator.test.ts @@ -23,13 +23,14 @@ * is a finer partition than the island. */ import { describe, it, expect } from "vitest"; -import { simulatePlacedBuildings } from "./simulator"; +import { ratedPlacementAt, simulatePlacedBuildings } from "./simulator"; import { getAnomaly } from "@reactor2/solver"; import { buildIslandContext } from "@reactor2/solver"; import { splitGridIntoIslands } from "@reactor2/solver"; import { simulateIsland } from "@reactor2/solver"; import { Rng } from "@reactor2/solver"; import type { + AnomalyDefinition, BuildingDefinition, EffectiveBuilding, PlacedBuilding, @@ -423,3 +424,165 @@ describe("the readout rates a board under the same rules the search does", () => expect(powerOf(withNone)).toBeCloseTo(powerOf(inland), 6); }); }); + +describe("a rated ceiling is what the row beside it was measured against", () => { + const TIDAL = getAnomaly("tidal_ascendancy"); + const CRYO = getAnomaly("cryo_nexus"); + const SINGULARITY = getAnomaly("singularity_isolation"); + + /** A board walled in by rock, so no tile of it is on the board's edge. */ + const inlandGrid = (w: number, h: number): Tile[][] => + Array.from({ length: h }, (_, y) => + Array.from({ length: w }, (_, x) => ({ + x, + y, + type: + x === 0 || y === 0 || x === w - 1 || y === h - 1 + ? ("rock" as const) + : ("grass" as const), + })), + ); + + /* + * The readout prints every figure as `used / total`, and the total has to be + * what the *tile* rated the tier at rather than what the roster authors it. + * + * These are the two ways that went wrong, and both were visible on screen + * with nothing failing anywhere. A shore cooler under Tidal Ascendancy cooled + * 8.35 against a printed total of 8 — a used figure past the ceiling it was + * measured against — while a Cryo Nexus cooler at full tilt reported 7.04 of + * a total of 8 it could never reach, because the 0.88 is in what the cooler + * *is* and not in what the pool charges it. + * + * So every case here asserts the same two things: the ceiling carries the + * rule's factor, and the live figure sits inside it. + */ + const chain = (x: number, y: number): Spec => [ + [x, y, "reactor", VALUES.reactor], + [x + 1, y, "generator", VALUES.generator], + [x + 2, y, "cooler", VALUES.cooler], + ]; + + const rowAt = (rows: PlacedBuilding[], x: number, y: number) => + rows.find((r) => r.x === x && r.y === y)!; + + /** Scores a board and reads one tile's ceilings back off it. */ + function scoreAndRate( + grid: Tile[][], + spec: Spec, + x: number, + y: number, + anomaly: AnomalyDefinition, + ) { + const placed = place(spec); + return { + rows: simulatePlacedBuildings(grid, DEFS, placed, undefined, anomaly), + max: ratedPlacementAt(grid, DEFS, placed, x, y, undefined, anomaly)!, + }; + } + + it("bonuses the ceiling of a shore tile, so the live figure fits inside it", () => { + // Every tile of a bare board is on its edge, and off the edge is water. + const grid = grassGrid(5, 1); + const cooler = scoreAndRate(grid, chain(0, 0), 2, 0, TIDAL); + const reactor = scoreAndRate(grid, chain(0, 0), 0, 0, TIDAL); + + expect(cooler.max.effectiveValue).toBeCloseTo(VALUES.cooler * 1.67, 6); + expect(reactor.max.effectiveValue).toBeCloseTo(VALUES.reactor * 1.67, 6); + + // The bug: measured against the plain roster, both of these were above 1. + expect( + rowAt(cooler.rows, 2, 0).coolingProvided / cooler.max.effectiveValue, + ).toBeLessThanOrEqual(1); + expect( + rowAt(reactor.rows, 0, 0).heatProduced / reactor.max.effectiveValue, + ).toBeLessThanOrEqual(1); + // ...and the used figure is genuinely past the authored tier, so this is + // not a ceiling that happens to fit an unscaled board: the reactor is + // moving more heat than the roster says it can, which is exactly what the + // card printed as `1.34k / 1k`. + expect(rowAt(reactor.rows, 0, 0).heatProduced).toBeGreaterThan( + VALUES.reactor, + ); + }); + + it("rates a generator's energy and heat ceilings for its tile", () => { + const grid = grassGrid(5, 1); + const { rows, max } = scoreAndRate(grid, chain(0, 0), 1, 0, TIDAL); + const generator = rowAt(rows, 1, 0); + + expect(max.effectiveValue).toBeCloseTo(VALUES.generator * 1.67, 6); + expect(max.energy).toBeCloseTo(VALUES.generator * 0.75 * 1.67, 6); + expect(generator.heatConsumed).toBeLessThanOrEqual(max.effectiveValue); + expect(generator.powerGenerated).toBeLessThanOrEqual(max.energy); + }); + + it("rates a Cryo cooler down, so a cooler at full tilt reaches its total", () => { + // Waste this pool cannot cover, so every cooler on the board is working + // flat out: `coolingProvided` is then exactly what the cooler is worth. + const grid = grassGrid(6, 1); + const spec: Spec = [ + [0, 0, "reactor", HELIO], + [1, 0, "generator", G1], + [2, 0, "cooler", VALUES.cooler], + ]; + const { rows, max } = scoreAndRate(grid, spec, 2, 0, CRYO); + + expect(max.effectiveValue).toBeCloseTo(VALUES.cooler * 0.88, 6); + expect(rowAt(rows, 2, 0).coolingProvided).toBeCloseTo( + max.effectiveValue, + 6, + ); + }); + + it("resolves a role isolation from the layout, not from the tile", () => { + /* + * The one rule that cannot be answered by a tile: a generator's rating is a + * function of what its neighbours *are*, so the ceiling for one tile is a + * function of every other placement on the board. Two boards identical + * except for a second generator beside the first rate it x2.5 and x0.8. + */ + const grid = inlandGrid(9, 3); + const alone = scoreAndRate(grid, chain(2, 1), 3, 1, SINGULARITY); + const crowded = scoreAndRate( + grid, + [...chain(2, 1), [4, 1, "generator", VALUES.generator]], + 3, + 1, + SINGULARITY, + ); + + expect(alone.max.effectiveValue).toBeCloseTo(VALUES.generator * 2.5, 6); + expect(crowded.max.effectiveValue).toBeCloseTo(VALUES.generator * 0.8, 6); + expect(rowAt(crowded.rows, 3, 1).heatConsumed).toBeLessThanOrEqual( + crowded.max.effectiveValue, + ); + }); + + it("is the authored tier under no anomaly, and null off the board", () => { + const grid = grassGrid(5, 1); + const spec = chain(0, 0); + const none = getAnomaly(undefined); + const max = ratedPlacementAt( + grid, + DEFS, + place(spec), + 0, + 0, + undefined, + none, + )!; + + expect(max.effectiveValue).toBe(VALUES.reactor); + expect(max.baseValue).toBe(VALUES.reactor); + // An empty tile and a tile that is not on the board are both "nothing to + // rate" — a ceiling invented for either would be the only figure on the + // card that was not measured. + expect( + ratedPlacementAt(grid, DEFS, place(spec), 4, 0, undefined, none), + ).toBeNull(); + expect( + ratedPlacementAt(grid, DEFS, place(spec), 99, 0, undefined, none), + ).toBeNull(); + }); +}); diff --git a/src/lib/simulation/simulator.ts b/src/lib/simulation/simulator.ts index 83d7471..8b74fdf 100755 --- a/src/lib/simulation/simulator.ts +++ b/src/lib/simulation/simulator.ts @@ -5,6 +5,7 @@ import { effectiveAtValue, getAnomaly, type AnomalyDefinition, + type EffectiveBuilding, type PrestigeScales, } from "@reactor2/solver"; import { unscoredPlacement } from "../data/placements"; @@ -80,38 +81,82 @@ function contextFor( } /** - * Returns the given placements with every derived figure filled in, in the same - * order they arrived. A placement on a tile that cannot be built on, or naming - * a building the catalogue does not have, keeps its zeroes. + * Every tier the session has resolved, so `ctx.rate` is handed the *same* + * object for a given (definition, tier) pair every time. + * + * `effectiveAtValue` mints a fresh record on every call — it has to, since it + * derives waste — and the context's rating memo is keyed on the base object's + * identity, which is exactly right for a solve (one roster, resolved once) and + * a guaranteed miss here: this runs on every placement change against a context + * kept alive for the whole session, so under a rule that scales anything each + * call inserted two more entries nothing would ever look up again. Interning the + * pair on this side makes those memos hit and bounds them by the catalogue — + * some 300 tiers — rather than by how long the player has been editing. + * + * Keyed on the definition object rather than its id, so a caller holding a + * different catalogue (the tests do) cannot collide with the shipped one, and + * weakly so a definition that goes away takes its tiers with it. The research + * is folded into the numbers, so a change of scales invalidates the lot. */ -export function simulatePlacedBuildings( - grid: Tile[][], - buildings: readonly BuildingDefinition[], - placedBuildings: PlacedBuilding[], - prestige?: PrestigeScales, - anomaly: AnomalyDefinition = getAnomaly(undefined), -): PlacedBuilding[] { - // Copies, with every derived figure reset: this returns a fresh set of rows - // rather than writing through to the caller's, and a figure left over from a - // previous score would survive on any building the walk below never reaches. - const results: PlacedBuilding[] = placedBuildings.map((pb) => - unscoredPlacement(pb.buildingId, pb.x, pb.y, pb.baseValue), - ); +let ratedTiers = new WeakMap< + BuildingDefinition, + Map +>(); +let ratedTiersPrestige: PrestigeScales | undefined; - if (!grid?.length || !grid[0]?.length || placedBuildings.length === 0) { - return results; +function tierAtValue( + def: BuildingDefinition, + baseValue: number, + prestige: PrestigeScales | undefined, +): EffectiveBuilding { + if (prestige !== ratedTiersPrestige) { + ratedTiers = new WeakMap(); + ratedTiersPrestige = prestige; } + let byValue = ratedTiers.get(def); + if (byValue === undefined) { + byValue = new Map(); + ratedTiers.set(def, byValue); + } + let tier = byValue.get(baseValue); + if (tier === undefined) { + tier = effectiveAtValue(def, baseValue, prestige); + byValue.set(baseValue, tier); + } + return tier; +} - const ctx = contextFor(grid, anomaly); - if (ctx.n === 0) return results; +/** A board turned into something `simulateIsland` can score. */ +interface BoardLayout { + placement: Placement; + /** Tile index -> the row that placement came from, or -1. */ + rowOf: Int32Array; + /** `(x, y)` -> tile index, for the placements that landed on one. */ + tileOf: (x: number, y: number) => number | undefined; +} +/** + * Lays the given placements onto an island's tiles, each rated for the tile it + * sits on. + * + * Shared by the two things that read a board: scoring it, and asking what one + * building on it was rated for. Both have to rate identically, and a second + * copy of this loop is how they would stop: the readout measured its figures + * against the plain roster for a while and printed a shore cooler as + * `8.35AC / 8AC`, a used figure larger than the total it was measured against. + */ +function layoutFor( + ctx: IslandContext, + buildings: readonly BuildingDefinition[], + placedBuildings: readonly PlacedBuilding[], + prestige: PrestigeScales | undefined, +): BoardLayout { // Tile index by position, so a placement's (x, y) finds its slot. const stride = ctx.n > 0 ? Math.max(...ctx.xs) + 1 : 1; const tileAt = new Map(); for (let t = 0; t < ctx.n; t++) tileAt.set(ctx.ys[t] * stride + ctx.xs[t], t); const placement: Placement = new Array(ctx.n).fill(null); - // Tile index -> the row that placement came from, so the report maps back. const rowOf = new Int32Array(ctx.n).fill(-1); for (let i = 0; i < placedBuildings.length; i++) { @@ -125,10 +170,50 @@ export function simulatePlacedBuildings( // Rated for the tile it sits on, the same way the search rates what it // places: under a shore bonus the two would otherwise print different // figures for the identical board. - placement[t] = ctx.rate(t, effectiveAtValue(def, pb.baseValue, prestige)); + placement[t] = ctx.rate(t, tierAtValue(def, pb.baseValue, prestige)); rowOf[t] = i; } + return { + placement, + rowOf, + tileOf: (x, y) => tileAt.get(y * stride + x), + }; +} + +/** + * Returns the given placements with every derived figure filled in, in the same + * order they arrived. A placement on a tile that cannot be built on, or naming + * a building the catalogue does not have, keeps its zeroes. + */ +export function simulatePlacedBuildings( + grid: Tile[][], + buildings: readonly BuildingDefinition[], + placedBuildings: PlacedBuilding[], + prestige?: PrestigeScales, + anomaly: AnomalyDefinition = getAnomaly(undefined), +): PlacedBuilding[] { + // Copies, with every derived figure reset: this returns a fresh set of rows + // rather than writing through to the caller's, and a figure left over from a + // previous score would survive on any building the walk below never reaches. + const results: PlacedBuilding[] = placedBuildings.map((pb) => + unscoredPlacement(pb.buildingId, pb.x, pb.y, pb.baseValue), + ); + + if (!grid?.length || !grid[0]?.length || placedBuildings.length === 0) { + return results; + } + + const ctx = contextFor(grid, anomaly); + if (ctx.n === 0) return results; + + const { placement, rowOf } = layoutFor( + ctx, + buildings, + placedBuildings, + prestige, + ); + for (const row of simulateIsland(placement, ctx, true).placements) { const i = rowOf[row.idx]; if (i < 0) continue; @@ -142,3 +227,65 @@ export function simulatePlacedBuildings( return results; } + +/** + * What the building on `(x, y)` was **rated for** on the board around it: the + * three ceilings every figure in its scored row was measured against. Null on a + * tile nothing buildable stands on. + * + * The readout needs this and cannot get it off the row. A `PlacedBuilding` + * carries `baseValue`, which is the *authored* tier value and deliberately never + * rewritten — a placement's tier is resolved back out of it by matching the + * catalogue, so a scaled value there would resolve as a higher tier and be + * scaled twice. And `effectiveAtValue` on its own answers a question with no + * tile in it, so under a rule that rates a tile above the roster the card's + * "used" figure walks straight past its own total. + * + * So the ceiling is resolved here, by the module that scored the row, from the + * same `layoutFor` — rather than in the component, which would be a second + * reading of the rules with nothing to keep the two in step. It is keyed on the + * *tile and the board* rather than carried on the row because the rows the card + * reads arrive from two places: `simulatePlacedBuildings` above, and a finished + * solve straight off the worker, whose protocol has no field for this and wants + * none (the rating is the island's own units). + * + * `placedBuildings` must be the whole board, not the one building: a + * role-isolation rule rates a building by *what its neighbours are*, so the + * answer for one tile is a function of every other. That rule is the reason for + * the `simulateIsland` call below — it is resolved per layout, into + * `ctx.ratedLayout`, and this asks for it exactly the way the scorer does rather + * than re-deriving a neighbour scan up here. Under every other rule + * `ratedLayout` is never written and the tile's own rating is the answer. + */ +export function ratedPlacementAt( + grid: Tile[][], + buildings: readonly BuildingDefinition[], + placedBuildings: readonly PlacedBuilding[], + x: number, + y: number, + prestige?: PrestigeScales, + anomaly: AnomalyDefinition = getAnomaly(undefined), +): EffectiveBuilding | null { + if (!grid?.length || !grid[0]?.length || placedBuildings.length === 0) { + return null; + } + + const ctx = contextFor(grid, anomaly); + if (ctx.n === 0) return null; + + const { placement, tileOf } = layoutFor( + ctx, + buildings, + placedBuildings, + prestige, + ); + const t = tileOf(x, y); + if (t === undefined) return null; + + const rated = placement[t]; + if (rated === null) return null; + if (ctx.isolation === null) return rated; + + simulateIsland(placement, ctx, true); + return ctx.ratedLayout[t] ?? rated; +} diff --git a/src/lib/state/layout.svelte.ts b/src/lib/state/layout.svelte.ts index 198f238..ab7b3d1 100755 --- a/src/lib/state/layout.svelte.ts +++ b/src/lib/state/layout.svelte.ts @@ -673,6 +673,14 @@ class LayoutState { async exitPreview(unlockedUpgrades: Record) { if (!this.isPreview) return; this.#previewCode = null; + // Dropped with the code, and it has to be: the rules are the *author's* + // timeline, and `hydrate()` below brings back a board that was never built + // under them. Both accessors that read them branch on `isPreview` first, so + // nothing would have printed a wrong figure today — but leaving the + // author's research hanging on the singleton makes that guard the only + // thing standing between a re-scored board and someone else's timeline, + // and a guard is a weaker guarantee than there being nothing to guard. + this.#previewRules = null; await this.hydrate(unlockedUpgrades); } @@ -691,16 +699,24 @@ class LayoutState { ): Promise { const code = this.#previewCode; if (code === null) throw new Error("There is no shared layout to import."); + const rules = this.#previewRules; // Cleared first: importBlueprint writes, and writes are barred in preview. + // The rules go with the code, for the reason `exitPreview` gives — and here + // there is a second: the adopted board is re-based to the visitor's own + // unlocks on the way in, so keeping the author's research would rate their + // own buildings under a timeline they were never in. this.#previewCode = null; + this.#previewRules = null; try { return await this.importBlueprint(code, unlockedUpgrades); } catch (err) { // The import was refused — at the island cap, or a board with two // transformers. Put the visitor back in front of what they were reading - // rather than dropping them onto a board they did not ask for. + // rather than dropping them onto a board they did not ask for, rules and + // all: they are still reading the author's board. this.#previewCode = code; + this.#previewRules = rules; throw err; } } @@ -1119,11 +1135,6 @@ class LayoutState { return this.isPreview ? this.#previewPrestige : this.#prestige; } - /** The anomaly a previewed board was built under, or null if unstated. */ - get previewAnomalyId(): string | null { - return this.#previewRules?.anomalyId ?? null; - } - setPrestige(scales: PrestigeScales) { if (this.#prestige === scales) return; this.#prestige = scales; diff --git a/src/lib/state/previewMode.test.ts b/src/lib/state/previewMode.test.ts index ed4332c..d675251 100755 --- a/src/lib/state/previewMode.test.ts +++ b/src/lib/state/previewMode.test.ts @@ -17,7 +17,7 @@ import { layoutState } from "./layout.svelte"; import { editorState } from "./editor.svelte"; import { encodeBlueprint } from "@reactor2/solver"; import { buildShareUrl, readSharedCode } from "../encoding/shareLink"; -import { findBuilding, levelValue } from "@reactor2/solver"; +import { findBuilding, getAnomaly, levelValue } from "@reactor2/solver"; import type { Tile } from "../types"; /** The tier the author had, and the (lower) tier the reader has unlocked. */ @@ -198,6 +198,77 @@ describe("preview mode", () => { expect(layoutState.isPreview).toBe(true); }); + /* + * The author's rules are as much a property of the preview as the author's + * tiers, and they end with it. + * + * What is observable is the pair of accessors: a previewed board reads the + * rules its own code names, and the visitor's board reads the player's. What + * is not observable — and is why `previewAnomalyId` went — is the held record + * between those two, which is cleared alongside `#previewCode` so that the + * `isPreview` branch in each accessor is a check rather than the only thing + * standing between a re-scored board and a stranger's timeline. + */ + const RULED = { + anomalyId: "tidal_ascendancy", + research: { absolute_zero: 4 }, + }; + + const ruledCode = () => + encodeBlueprint( + grass(3, 2), + [{ x: 0, y: 0, buildingId: "cooler1" }], + undefined, + RULED, + ); + + it("rates a previewed board under the rules its own code names", async () => { + await layoutState.loadPreview(await ruledCode(), READER_UNLOCKS); + + expect(layoutState.placementAnomaly.id).toBe("tidal_ascendancy"); + expect(layoutState.placementPrestige?.cooler).toBeGreaterThan(1); + }); + + it("hands the player's own rules back when the preview ends", async () => { + layoutState.setAnomaly(getAnomaly("cryo_nexus")); + try { + await layoutState.loadPreview(await ruledCode(), READER_UNLOCKS); + expect(layoutState.placementAnomaly.id).toBe("tidal_ascendancy"); + + await layoutState.exitPreview(READER_UNLOCKS); + + expect(layoutState.placementAnomaly.id).toBe("cryo_nexus"); + expect(layoutState.placementPrestige).toBeUndefined(); + } finally { + layoutState.setAnomaly(getAnomaly(undefined)); + } + }); + + it("keeps the author's rules when an adoption is refused", async () => { + // Refused at the transformer rule, so the visitor is still reading the + // author's board — and it is still the author's rules that rate it. The + // rules are dropped before the import writes, for the same reason the code + // is, so this is the restore on the way back. + const twoTransformers = await encodeBlueprint( + [ + [ + { x: 0, y: 0, type: "transformer" }, + { x: 1, y: 0, type: "transformer" }, + ], + ], + [], + undefined, + RULED, + ); + await layoutState.loadPreview(twoTransformers, READER_UNLOCKS); + + await expect(layoutState.adoptPreview(READER_UNLOCKS)).rejects.toThrow( + /transformers/, + ); + expect(layoutState.isPreview).toBe(true); + expect(layoutState.placementAnomaly.id).toBe("tidal_ascendancy"); + }); + it("reports an unreadable code rather than half-loading it", async () => { expect(await layoutState.loadPreview("not-a-code", READER_UNLOCKS)).toBe( false, diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index 7cc90cf..0ef011a 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -342,9 +342,15 @@ class SolverState { if (!effectiveBuildings.length) return 0; const hasCoolingSupport = canCoolDirectProducer(effectiveBuildings); - // The same anomaly the run will be planned under: under a shared cooling - // pool the split keeps one-tile patches, and a bound that decomposed the - // board differently from the run would be measured against another board. + // The same anomaly the run will be planned under, because the anomaly + // changes the decomposition itself: under a shared cooling pool there are + // no islands at all — `splitGridIntoIslands` hands the whole board over as + // one, unbuildable scraps and one-tile patches included, since a pool that + // spans the map makes every grass tile part of one problem. A bound that + // decomposed the board differently from the run would be measured against + // another board. It is passed to the estimate below for a second reason: a + // terrain bonus rates some tiles above the roster, so a bound computed on + // the plain roster is one a real layout walks past. const subGrids = splitGridIntoIslands( grid, hasCoolingSupport, @@ -776,9 +782,12 @@ class SolverState { { attempts: mode.attempts, attemptBudgetMs: mode.attemptBudgetMs, - // The rules this timeline runs under. The research is folded into - // the roster the coordinator resolves and lands; the anomaly is - // carried the whole way and not yet acted on. + // The rules this timeline runs under, and both halves have to be + // here: the research reaches the search only through `prestige`, + // because the coordinator resolves the roster itself, and the anomaly + // only through `anomalyId`, because every rule that reads it is + // resolved on the far side of the worker boundary — per tile, per + // layout, or in how the board is split. anomalyId: configState.activeAnomaly.id, prestige: configState.prestige, }, diff --git a/src/lib/worker/workerClient.ts b/src/lib/worker/workerClient.ts index 193898e..afd605c 100755 --- a/src/lib/worker/workerClient.ts +++ b/src/lib/worker/workerClient.ts @@ -47,10 +47,22 @@ export interface SolveRunOptions { /** * The timeline's anomaly, by id. Omitted means the base rules. * - * Carried the whole way to `solveIsland` and **not yet acted on** — the - * rules are modelled and threaded, not implemented. Time Lab research needs - * nothing here: it resolves into `unlockedUpgrades`' effective roster before - * a run starts. + * Carried the whole way to `solveIsland`, where each rule is resolved + * wherever its inputs are settled: a terrain bonus per tile when the island + * context is built, a role isolation per layout inside `simulateIsland`, and + * a shared cooling pool by handing the board over whole rather than split. + * Every one of those is downstream of here, so this field is the only thing + * that decides whether the search runs the timeline the player is in — omit + * it and a run comes back with a layout that is perfectly valid under rules + * nobody is playing under, which nothing on screen would contradict. + * + * It stays an **id** rather than the resolved table entry: it has a worker + * boundary to cross, and a string survives structured cloning without + * anything having to be true about the shape of `AnomalyDefinition`. The far + * side resolves it again through `getAnomaly`, which is total. + * + * Time Lab research needs nothing here: it resolves into `unlockedUpgrades`' + * effective roster before a run starts. */ anomalyId?: AnomalyId; /** From f5077f8a7f18d8e775aa4023571e7ccda9a30bd6 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:39:57 +0300 Subject: [PATCH 11/21] Count in the board's units, bound by class, and seed for the pool A review of the anomaly work on this branch, measured against a worktree of main. Nothing was threaded wrongly and no rule was misapplied; what was found is three places where a stage reasoned at the wrong granularity, each silent in the way this class of mistake always is -- a layout simply worth less than it says, or a figure printed beside it that nobody can check. The base path is untouched throughout. Seed construction and a fixed 40k-step walk on maps 1, 3 and 7 return bit-identical power against main, throughput sits inside noise, and the goldens regenerate byte for byte. **The counting stages counted the roster, not the board.** `solveIsland` drew its four pools straight from `effectiveBuildings`, so under a cooling pool the hub fits, the composition targets and the polish candidates all valued a cooler at its authored figure while the board holds x0.88 of it. The retarget was the visible casualty: every target it proposed on maps 3 and 7 was 8-14% short of the waste it planned to make, and under a pool a short board is offline entirely, so the stage could never return a layout that beat the one in hand. `ctx.rateRole` is the per-role half of `rate` on its own, and the pools are rated through it once; `rate` is idempotent, so an entry that already carries its role's rating is handed straight back at the write. Map 7 at 15s over three seeds went 288/281/288AC to 294/294/294AC, map 3 144/144/144AC to 145/145/144AC. **The bound scaled its own answer where it could have scaled its inputs.** A rule that scales a whole role is a property of the roster, so it now goes into the roster the LP runs on (`roleRatedRoster`) rather than onto the LP's result. Scaling the result by the largest factor rated reactors and coolers up as well: under Singularity the bound was 2.3x the tight one, so a layout at 97.5% of what a generator-bound roster can reach read as 51% layout efficiency. A bound that is generous only makes the figure read low, but this was generous enough to make it meaningless. Terrain is the half that genuinely cannot go into the roster, because which tiles carry it is a property of the board -- and rating a whole island at its best tile is very loose where half of it is inland. `estimateIslandBound` now splits an island into its shore and inland tile counts and runs the LP over both classes at once: a shore reactor may still feed an inland generator, since the bound relaxes adjacency away, and the tiles left over are filled by a fractional knapsack over power per unit of cooling. Integer layouts are inside that relaxation, and a mixed island returns the lesser of it and the old whole-island figure, so it is never looser than what it replaces. All-shore and all-inland islands return the old expressions bit for bit. Roughly 24% tighter on every shipped map: map 3 goes 248AC to 191AC, against a 15s solve of 178AC. **The Cryo seed spent its best tiles on buildings that need no neighbours.** Under a pool a cooler contributes nothing by where it stands, so every well-connected tile should hold engine and the coolers belong on the scraps -- which is exactly what the seed did not do: each hub ceiled its own coolers onto its own neighbours, over-cooling by ~40%, and left every one-tile patch empty because no hub fits on one. It is sized against a phantom cooler of infinite cooling now, so the fits ask for none; the cooling is charged as a board-wide debt instead, counted into each candidate's rank, reserved out of the free tiles so a hub cannot crowd out what keeps it online, and paid off afterwards onto the least-connected tiles first. Seed efficiency went from ~76% of the bound to 98-99% with no tile left empty. Map 7 now returns its bound at both 5s and 15s in 6 of 6 runs; map 8 at 5s gains 1.9%; map 3 was at its bound already and now reaches it every run rather than two in three. The local passes stay, and the stronger of the six seeds starts the search, so a pooled seed that came back weaker could not cost anything. Two candidate changes were measured and are NOT here, with their numbers left in place so the next person does not re-run them blind. Scaling the annealing temperature by the island's rating ceiling -- the open question the docs have carried since Tidal landed -- moved Tidal on map 3 from 176/178/179AC to 179/181/178AC and Singularity on map 7 from 271/271/271AC to 268/267/271AC, inside run-to-run noise either way. Aiming the walk's "cool a starved producer" move at an empty tile under a pool, where adjacency buys nothing and the move otherwise writes over the reactor feeding the producer it means to save, changed nothing at all on maps 3 and 7. One existing test was wrong in its premise rather than its assertion: it called a bare 3x3 all-shore, when the centre tile has all eight of its neighbours on the board and is inland. The two-class bound is tighter there, which is what caught it. --- CLAUDE.md | 59 ++- docs/SOLVER.md | 137 ++++-- packages/solver/src/solver/context.ts | 18 + packages/solver/src/solver/island.ts | 456 ++++++++++++++---- packages/solver/src/solver/placementSearch.ts | 315 +++++++++--- packages/solver/tests/anomalies.test.ts | 124 ++++- packages/solver/tests/island.test.ts | 212 +++++++- 7 files changed, 1115 insertions(+), 206 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5e0c02..f5a8c85 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,6 +270,15 @@ timeline. `docs/game-logic.md` is the authority on what each one does and 271AC to 294AC, map 3 at 20s 139AC to 145AC, and map 1 — one landmass, already at 98% of its bound — is unchanged. The x0.88 has to be earned back before any of that shows. + + **The seed is the one stage that 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 + it off onto the least-connected free tiles, scraps first; the base seed spent + a hub's best-connected tiles on coolers and never filled a scrap. The seed + goes from ~76% of the bound to ~99% on maps 3, 7 and 8 and map 7 now returns + its bound at 5s; `docs/SOLVER.md` has the tables. - **Off the board counts as water**, which is a fact about our data rather than about the game: the game has one global map with open water between islands, and our eight boards are rectangles cut out of it, so the water past an edge is @@ -411,24 +420,38 @@ timeline. `docs/game-logic.md` is the authority on what each one does and one a real layout walks past, and "no layout may ever beat it" is an invariant the rest of the solver is entitled to assume: Magma Rift reported 119.9% layout efficiency before the terrain case existed, and a generator-bound - roster on a 3x3 under Singularity read 163.8% before the isolation case did - (65.5% now). The bound rates a whole island at its best tile - (`islandMaxScale`), which is loose where only part of it qualifies — or where - only part of the roster is scaled — and is the right trade: a bound that can - be beaten is worthless, one that is generous only makes the efficiency figure - read low. Scaling the estimate's *result* is sound because it is positively - homogeneous of degree 1 in the roster's figures, which is also what lets a - rule scaling one role be answered with one island-wide number. - - **`islandMaxScale` is an exhaustive switch with no `default`**, so a fifth - rule shape fails to typecheck here rather than silently returning 1 — which is - precisely what `role_isolation` did while a layout beat the bound by 64%. It - allows both variants of an isolation multiplier, since which one a tile gets - is a function of the layout and a search free to keep generators apart rates - every one of them at the bonus; it is 1 for the shipped pool, whose ×0.88 only - ever costs cooling; and under a terrain list it errs high wherever the shore - mask cannot decide the question, which is any list that is not exactly - `["water"]`. + roster on a 3x3 under Singularity read 163.8% before the isolation case did. + A rule that scales a whole role goes **into the roster** the bound is run on + (`roleRatedRoster`: coolers ×0.88 under a pool, generators ×2.5 under + Singularity), where the bound is exact in it; scaling the LP's result by the + largest factor instead rated reactors and coolers up too and left the + Singularity bound 2.3x the tight one, so a near-optimal layout read 40%. A + rule that scales a *tile* splits the island's tile budget by class + (`estimateIslandBound`, `estimateMixedIslandMaxPower`): a shore building is + worth the shore-scaled roster, an inland one the plain roster, and both pay + into the one heat and the one cooling total, since the bound relaxes + adjacency away. Rating the whole island at its best tile (`islandMaxScale`, + kept as the ceiling) had every inland tile standing on the coast, and the + coast is 36-44% of the shipped grass — a quarter loose, with Magma Rift's + best 15s layout reading 72% of it against 93% now. The engine side is + enumerated integer and the producer/cooler remainder is a fractional + knapsack, which is what keeps it under 10ms; a mixed island takes the + smaller of that and the whole-island figure — both are bounds, so the + minimum is, and a two-tile island's fractional cooler cannot make it looser + than it was. An all-shore island is the plain LP at the multiplier, bit for + bit; an all-inland one the plain LP. Scaling a result is sound because the + estimate is positively homogeneous of degree 1 in the roster's figures, and + that is still what the ceiling rests on where the mask cannot say which + tiles it reaches. + + **Both switches are exhaustive with no `default`**, so a fifth rule shape + fails to typecheck rather than silently returning 1 or the plain roster — + which is precisely what `role_isolation` did while a layout beat the bound by + 64%. The roster allows both variants of an isolation multiplier, since which + one a tile gets is a function of the layout and a search free to keep + generators apart rates every one of them at the bonus; and under a terrain + list the tile half errs high wherever the shore mask cannot decide the + question, which is any list that is not exactly `["water"]`. The anomaly crosses the worker boundary **as an id**, resolved again on the far side, so the message stays a string rather than a table entry that has to diff --git a/docs/SOLVER.md b/docs/SOLVER.md index d4bf242..b64859e 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -230,32 +230,73 @@ stage's *gate* into the layout's units, which is above and is not a tuning choic still scored from the plain roster, and nothing in the tree retargets toward the bonus. **The bound takes the anomaly too, and every rule of it.** `estimateTotalMaxPower` rates each -island at its best tile (`islandMaxScale`), because a bound computed on the plain roster is one a -rated layout walks past — and "no layout may ever beat it" is an invariant the rest of the solver +island under the rule in force (`estimateIslandBound`), because a bound computed on the plain +roster is one a rated layout walks past — and "no layout may ever beat it" is an invariant the rest of the solver is entitled to assume, not a presentation detail. Magma Rift reported 119.9% layout efficiency before the terrain case existed, and a generator-bound roster on a 3×3 under Singularity read -163.8% before the isolation case did; both are now the intended side of 100%, the 3×3 at 65.5%. - -`islandMaxScale` is an exhaustive switch with **no `default`**, deliberately: a fifth rule shape -added to `AnomalyDefinition` fails to typecheck here — "function lacks ending return statement" — -rather than silently returning 1, which is what `role_isolation` did while a layout was walking -past the bound by 64%. It answers `max(isolated, crowded, 1)` under a role isolation, because which -of the two variants a tile gets is a function of the layout rather than of the island and a search -free to keep generators apart rates every one of them at the bonus; `max(coolerMultiplier, 1)` -under a shared pool, which is 1 for the shipped ×0.88 and leaves the bound untouched; and the -multiplier under a terrain list it cannot fully decide. It can decide only a list that is exactly -`["water"]`, since the shore mask is the one thing that knows off-board counts as water, while -`terrainScales` tests rock and tree against the grid — so a landlocked island under a -`["water", "rock"]` anomaly errs high rather than staying at 1. +163.8% before the isolation case did; both are now the intended side of 100%. + +**A rule that scales a whole role goes into the roster; one that scales a tile scales the result.** +`roleRatedRoster` rates the roster's coolers ×0.88 under a pool and its generators +×max(isolated, crowded) under a role isolation, and the LP runs on that roster — where the bound is +exact in the rule. It used to scale the LP's *result* by the largest factor instead, which under +Singularity rated reactors and coolers ×2.5 as well: 2.3× the tight bound on map 1, so a +near-optimal layout read as 40% layout efficiency, and on a generator-bound roster (where the +bonus genuinely pays, +28% over the base rules on map 3) 51% for a layout at 97.5% of the tight +one. Both variants of an isolation are allowed because which one a tile gets is a function of the +layout rather than of the island, and a search free to keep generators apart rates every one of +them at the bonus. The pool's ×0.88 lowers the bound only where cooling binds — a roster bound by +its direct producers reads the same either way. + +`islandMaxScale` keeps the per-tile half as a **ceiling**: the multiplier under a terrain rule that +reaches any tile of the island, and 1 for the role-shaped rules, whose factor is already in the +roster. Where the shore mask can say *which* tiles it reaches, `estimateIslandBound` goes on to the +two-class bound below; anywhere else it scales the plain LP by the ceiling. The mask can decide +only a list that is exactly `["water"]` (`ratedTileCount`), since it is the one thing that knows +off-board counts as water, while `terrainScales` tests rock and tree against the grid — so a +landlocked island under a `["water", "rock"]` anomaly errs high rather than staying at 1. Both +switches are exhaustive with **no `default`**, deliberately: a fifth rule shape added to +`AnomalyDefinition` fails to typecheck in each — "function lacks ending return statement" — rather +than silently returning 1 or the plain roster, which is what `role_isolation` did while a layout +was walking past the bound by 64%. + +**The two-class bound is the same LP with the tile budget split by class.** Rating a whole island +at its best tile treats every inland tile as if it stood on the coast, and on the shipped maps the +coast is 36-44% of the grass: the bound ran a quarter loose, and Magma Rift's best 15s layout read +72% of it. `estimateMixedIslandMaxPower` gives the shore tiles and the inland tiles separate tile +budgets, rates a shore building at the shore-scaled roster (`scaleEffectiveBuilding` at the +multiplier, so the snapped waste is the one the layout carries) and an inland one at the plain +roster, and lets both pay into the one heat total and the one cooling total — a shore reactor may +feed an inland generator and an inland cooler may cover a shore generator's waste, because the +bound relaxes adjacency away, and any layout that respects adjacency is feasible in the relaxation +with an objective no larger. What stays integer is chosen for cost. The engine — how many tiles of +each class, and the reactor/generator split within each — is enumerated exactly; the tiles each +class has left go to direct producers or coolers *fractionally*, which makes the remainder one +fractional knapsack over three items (heat, inland producers, shore producers), filled by power per +unit of cooling. Enumerating that split integer too was measured at 250ms on the largest island +against under 10ms for this, and the two agree to the last bit on every shipped map: at full +unlocks no direct producer competes with an engine, so the relaxed split never moves. The generator +ratios are taken across *both* rosters — a shore generator's re-derived waste ratio is not +bit-identical to the inland one, and a bound has to allow the greedier of each. + +Three things about how it is joined on. An island with no inland tile *is* one class, so it is +spelled as the plain LP at the multiplier — the figure it always was, bit for bit — and one with no +shore tile is the plain LP. A mixed island takes the **smaller** of the two-class figure and the +whole-island one: both are bounds, so the minimum is, and it is what keeps an island of two or +three tiles, where a fractional cooler is worth more than a whole one, from reading looser than it +did before the split existed. And it is never below the base bound, because every shore figure is +at least the plain one. At full unlocks it sits at 0.758-0.771 of the whole-island figure on all +eight maps, and `--map 3 --anomaly tidal_ascendancy --time 15` reads 93% of it against 72% before; +`island.test.ts` pins that ratio, the bound property on a mixed board, and both ends. Scaling the estimate's *result* is sound because `estimateIslandMaxPower` is positively homogeneous of degree 1 in the roster's figures: multiply every one of them by `k` and each of `hFirst`, `dRest`, `dFirst` and `hRest` scales by `k` while every ratio in it is invariant. That is also what -lets a rule scaling only *some* buildings be answered with one island-wide number — any layout -feasible under the partial scaling is feasible in the all-scaled world with an objective no larger. -It is loose where only part of an island qualifies, or where only part of the roster is scaled, -which is the right way to be wrong: a bound that can be beaten is worthless, one that is generous -only makes the efficiency figure read low. +lets a rule scaling only *some* buildings be answered by scaling only those entries — any layout +feasible under the rule is feasible in that roster with an objective no larger. The ceiling is loose +where only part of an island qualifies and the mask cannot say which part, which is the right way +to be wrong: a bound that can be beaten is worthless, one that is generous only makes the +efficiency figure read low. `shared_cooling` is the odd one out entirely: it is the only shape that changes more than a building's figures. **Its pool is the whole board** — the game's "island" is the map, Gale Hills @@ -293,21 +334,55 @@ what a cooler is worth and what the game shows for it, not a charge levied at th therefore carries a per-role factor beside its per-tile one; only ever one of the two is in force, since only one anomaly runs at a time. +**The stages that count rather than place read that role factor too**, through `ctx.rateRole`: +`solveIsland` rates its four pools by role before anything draws on them, so a hub fit, a +composition target and the polish candidates all count a cooler at what the board will hold. They +counted the plain figure once, and under a pool that is exactly ×0.88 too little cooling: every +target the retarget proposed on maps 3 and 7 came out 8–14% short of the waste it planned to make, +which under a pool is a board that is offline *entirely* — so the stage never produced a layout that +could beat the one in hand, silently. With the pools rated, map 7 at 15s over three seeds went +288/281/288AC to 294/294/294AC and map 3 144/144/144AC to 145/145/144AC. `rate` is idempotent, so a +pool entry already carrying its role's rating is handed straight back at the write; under every +other rule `rateRole` is the identity and the pools are the roster's own objects. + It is worth real power, and most on a fragmented board, since the x0.88 has to be earned back first: map 7 at 25s goes 271AC to 294AC, map 3 at 20s 139AC to 145AC, and map 1 — one landmass, already at 98% of its bound — is unchanged. -**One open question, deliberately left open.** `hillClimb`'s `moveScale` — the figure the annealing -temperature is derived from — is read off the **plain** roster: the top generator's energy at full -fill. Under any rule that rates a tile above the roster the moves the walk is judging are worth -more than that calibration assumes, so the walk runs colder than intended: up to ×1.67 under Tidal -and ×2.5 under Singularity. It is plausible that it costs something and nobody has measured how -much. It is left alone because the previous, *hotter* calibration was itself the bug the comment -above it records — scaling by total power made large islands accept ~23% of moves each costing 7% -of the layout, and the walk never climbed back — and because this repo does not move a tuning -constant on an argument. Settling it means the usual measurement: several seeds at a realistic -budget, on both a shore-heavy map under Tidal and a fragmented one under Singularity, against the -unchanged search. Don't change it blind, and don't assume it is fine because nothing fails. +**The seed is built engine-first under the pool**, and it is the one stage that does branch on +the rule. The base seed places each hub's coolers on the generator's own neighbours and never fills +a scrap, because no hub fits there — right where cooling is adjacency-bound, and exactly wrong +under a pool, where a cooler's position contributes nothing but the tile it stands on. So under +`shared_cooling` `constructSeed` sizes every hub against a phantom cooler (`POOL_COOLER_VALUE`, +`Infinity`, never written onto a placement) so all of a hub's neighbour slots go to reactors, ranks +each candidate by power per tile with the coolers it will need counted as a fractional board-wide +cost, reserves those tiles out of the free ones before each claim (the debt is re-simulated after +every claim rather than summed from the fits, since hubs down beside each other share reactors), +and then pays the debt off onto the least-connected free tiles, scraps first. A short pool is an +all-offline board that `stabilize` would strip to nothing, so the fill adds coolers until the board +simulates alive, and takes the newest hub back if it runs out of tiles first. The same three orders +run engine-first as well as locally and the strongest of the six seeds starts the search, so the +pooled path can never hand the walk a weaker start than the base one did. + +At full unlocks the seed itself goes from 75–77% of the bound to 98–99% on maps 3, 7 and 8, with +every scrap filled where before 5–16 tiles were left empty. What the search returns moved with it, +same seeds, three runs each: map 7 went 293.8/293.8/293.8AC to 297.6/297.6/297.6AC at both 5s and +15s — the bound — map 8 at 5s 337.5/337.4/337.5AC to 343.8/343.8/343.8AC and at 15s +341.5/341.5/343.8AC to 343.8/343.8/343.8AC, and map 3 at 5s 145.4/143.6/143.6AC to 145.4 in all +three. Nothing came back lower anywhere. `anomalies.test.ts` pins the seed on a block beside three +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. + +**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 ×1.67 +under Tidal and ×2.5 under Singularity. Scaling it by `islandRatingCeiling` was measured at 15s over +three seeds: Tidal on map 3 went 176/178/179AC to 179/181/178AC, Singularity on map 3 142/142/139AC +to 140/142/142AC and on map 7 271/271/271AC to 268/267/271AC — inside run-to-run noise either way. +And the walk's "add a cooler beside a starved producer" move, which under a short pool starves every +producer at once and so writes over a random occupied neighbour, was aimed at an empty tile instead: +identical power on maps 3 and 7 over the same three seeds. Neither pays for a second code path, so +neither is in. A longer budget and more seeds than that is the least a re-measurement should use. ### `island.ts` — the window an island is solved in diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index 98cc69f..a74a3a2 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -158,6 +158,20 @@ export interface IslandContext { * once and then handed back. */ rate(tile: number, building: EffectiveBuilding): EffectiveBuilding; + /** + * The per-role half of `rate` alone: `building` at whatever a rule scales its + * whole role by, on every tile alike, and the building itself where no rule + * does. + * + * For the stages that count rather than place — hub fits, composition + * targets, the polish candidates — which have no tile to ask `rate` about and + * yet must reason in the units the board will hold. Under a shared cooling + * pool every cooler is worth x0.88 wherever it stands, and a count made from + * the plain figure under-provisions cooling by exactly that: every target the + * retarget proposed was short by 8-14%, and under a pool a short board is an + * offline board. + */ + rateRole(building: EffectiveBuilding): EffectiveBuilding; /** * The role-isolation rule in force, or `null` under every other anomaly. * @@ -439,6 +453,10 @@ export function buildIslandContext( crowded ? isolation.crowded : isolation.isolated, ); }, + rateRole(building: EffectiveBuilding): EffectiveBuilding { + if (roleScale === null) return building; + return scaledBy(building, roleScale[building.type] ?? 1); + }, rate(tile: number, building: EffectiveBuilding): EffectiveBuilding { if (tileScale === null && roleScale === null) return building; // Idempotent: a building arriving with another tile's rating is taken diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index 3944ce7..33fe46c 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -5,10 +5,12 @@ import { GENERATOR_WASTE_RATIO, } from "./constants"; import { generatorEnergyRatio, generatorWasteRatio } from "./physics"; +import { scaleEffectiveBuilding } from "../data/effectiveBuildings"; import type { AnomalyDefinition, EffectiveBuilding, IslandSubGrid, + TerrainAffinityAnomaly, Tile, } from "./types"; @@ -305,6 +307,68 @@ export function splitGridIntoIslands( return subGrids; } +/** + * The roster reduced to the figures the bound runs on: the best of each role, + * with the ratios a generator's power and waste are bounded through. + * + * The bound must not sit below a layout the solver actually builds, so it + * takes the greediest energy ratio and the stingiest waste ratio any unlocked + * generator can offer -- they need not come from the same tier. A mixed DP + * roster is likewise bounded by its best net power and its smallest cooling + * appetite -- each real DP produces no more and cools no less. The sentinels + * are left in place for the caller to resolve, because the two-class bound + * below takes its ratios across two rosters before it falls back. + */ +interface RosterFigures { + cVal: number; + rVal: number; + gVal: number; + dpNet: number; + /** `Infinity` when the roster has no direct producer. */ + dpWaste: number; + hasCooler: boolean; + hasDp: boolean; + /** `-Infinity` when the roster has no generator. */ + energyRatio: number; + /** `Infinity` when the roster has no generator. */ + wasteRatio: number; +} + +function rosterFigures(roster: EffectiveBuilding[]): RosterFigures { + const f: RosterFigures = { + cVal: 0.0, + rVal: 0.0, + gVal: 0.0, + dpNet: 0.0, + dpWaste: Infinity, + hasCooler: false, + hasDp: false, + energyRatio: -Infinity, + wasteRatio: Infinity, + }; + + for (const b of roster) { + if (b.type === "cooler") { + f.hasCooler = true; + if (b.effectiveValue > f.cVal) f.cVal = b.effectiveValue; + } else if (b.type === "generator") { + if (b.effectiveValue > f.gVal) f.gVal = b.effectiveValue; + const e = generatorEnergyRatio(b); + if (e > f.energyRatio) f.energyRatio = e; + const w = generatorWasteRatio(b); + if (w < f.wasteRatio) f.wasteRatio = w; + } else if (b.type === "reactor") { + if (b.effectiveValue > f.rVal) f.rVal = b.effectiveValue; + } else { + f.hasDp = true; + if (b.energy > f.dpNet) f.dpNet = b.energy; + if (b.waste < f.dpWaste) f.dpWaste = b.waste; + } + } + + return f; +} + /** * A TRUE upper bound on a single island's power: no layout on this island can * exceed it, so the "layout efficiency" figure it feeds never reads above 100% @@ -336,39 +400,9 @@ function estimateIslandMaxPower( const buildableTiles = island.tileCount; if (buildableTiles <= 0) return 0.0; - let cVal = 0.0; - let rVal = 0.0; - let gVal = 0.0; - let dpNet = 0.0; - let dpWaste = Infinity; - let hasCooler = false; - let hasDp = false; - // The bound must not sit below a layout the solver actually builds, so it - // takes the greediest energy ratio and the stingiest waste ratio any unlocked - // generator can offer -- they need not come from the same tier. - let energyRatio = -Infinity; - let wasteRatio = Infinity; - - for (const b of roster) { - if (b.type === "cooler") { - hasCooler = true; - if (b.effectiveValue > cVal) cVal = b.effectiveValue; - } else if (b.type === "generator") { - if (b.effectiveValue > gVal) gVal = b.effectiveValue; - const e = generatorEnergyRatio(b); - if (e > energyRatio) energyRatio = e; - const w = generatorWasteRatio(b); - if (w < wasteRatio) wasteRatio = w; - } else if (b.type === "reactor") { - if (b.effectiveValue > rVal) rVal = b.effectiveValue; - } else { - // A mixed DP roster is bounded by its best net power and its smallest - // cooling appetite -- each real DP produces no more and cools no less. - hasDp = true; - if (b.energy > dpNet) dpNet = b.energy; - if (b.waste < dpWaste) dpWaste = b.waste; - } - } + const figures = rosterFigures(roster); + const { cVal, rVal, gVal, dpNet, hasCooler, hasDp } = figures; + let { dpWaste, energyRatio, wasteRatio } = figures; if (!hasCooler || cVal <= 0) return 0.0; if (!hasDp) dpWaste = 0.0; @@ -454,7 +488,261 @@ function bestHeatForEngineTiles( } /** - * The largest multiplier any building on this island can be rated at. + * `bestHeatForEngineTiles` for engine tiles of two classes: `shoreEngine` of + * the `nEngine` carry the shore figures, the rest the inland ones. + * + * 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. + */ +function bestHeatForMixedEngineTiles( + nEngine: number, + shoreEngine: number, + inland: RosterFigures, + shore: RosterFigures, +): number { + if (nEngine < 2) return 0.0; + + const inlandEngine = nEngine - shoreEngine; + const slope = inland.rVal + inland.gVal; + let best = 0.0; + 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, + ); + if (heat > best) best = heat; + } + } + return best; +} + +/** + * `estimateIslandMaxPower` for an island whose tiles come in two classes: the + * `shoreTiles` a terrain bonus rates at `multiplier`, and the rest at 1. + * + * The single-class bound scaled by the multiplier rates every inland tile as if + * it stood on the coast, and on the shipped maps the coast is 36-44% of the + * grass — so the bound ran a quarter loose and Magma Rift's best layouts read + * 72% of it. This asks the same question with the tile budget split in two: a + * shore building is a shore-rated building and an inland one is not, and both + * pay into the one heat and the one cooling total, since the bound relaxes + * adjacency away and a shore reactor may feed an inland generator. + * + * What stays integer and what does not is chosen for cost. The engine — + * how many tiles of each class, and how each class splits between reactors + * and generators — is enumerated exactly. The tiles left over on each class go + * to direct producers or coolers **fractionally**: a producer tile then costs + * its own waste plus the cooling the cooler it displaced would have given, and + * the whole remainder is one fractional knapsack over three items (heat, inland + * producers, shore producers), solved by filling the best power-per-cooling + * first. Enumerating that split integer too was measured at 250ms on the + * largest island against under 10ms for this, and the two agree to the last + * bit on every shipped map — at full unlocks no direct producer competes with + * an engine, so the relaxed split never moves. It is a relaxation, so still a + * bound; on a toy roster with a strong producer it sits up to ~1% above the + * integer figure, and on an island of two or three tiles a fractional cooler + * can put it above the whole-island figure it exists to tighten — which is why + * the caller takes the smaller of the two. + * + * The ratios a generator is bounded through are taken across **both** rosters: + * a shore generator's waste is re-derived from its scaled pair and snapped, so + * its waste ratio is not bit-identical to the inland one, and a bound on the + * mixed board has to allow the greedier of each. + */ +function estimateMixedIslandMaxPower( + buildableTiles: number, + shoreTiles: number, + roster: EffectiveBuilding[], + multiplier: number, +): number { + if (buildableTiles <= 0) return 0.0; + const inlandTiles = buildableTiles - shoreTiles; + + const inland = rosterFigures(roster); + const shore = rosterFigures( + roster.map((b) => scaleEffectiveBuilding(b, multiplier)), + ); + if (!inland.hasCooler || inland.cVal <= 0) return 0.0; + + let energyRatio = Math.max(inland.energyRatio, shore.energyRatio); + let wasteRatio = Math.min(inland.wasteRatio, shore.wasteRatio); + if (energyRatio === -Infinity) energyRatio = GENERATOR_ENERGY_RATIO; + if (wasteRatio === Infinity || wasteRatio <= 0) + wasteRatio = GENERATOR_WASTE_RATIO; + const inlandDpWaste = inland.hasDp ? inland.dpWaste : 0.0; + const shoreDpWaste = shore.hasDp ? shore.dpWaste : 0.0; + + // 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; + const canDp = inland.hasDp && buildableTiles >= 2; + if (!canHub && !canDp) return 0.0; + + // The knapsack's three items in fill order — most power per unit of cooling + // first, which is optimal for a fractional knapsack. A producer tile's + // weight includes the cooler it displaces, so the budget is every remaining + // tile as a cooler. The order is fixed by the roster, so it is settled once. + const values = [energyRatio, inland.dpNet, shore.dpNet]; + const weights = [ + wasteRatio, + inlandDpWaste + inland.cVal, + shoreDpWaste + shore.cVal, + ]; + const order = [0, 1, 2].sort((a, b) => { + const ra = weights[a] <= EPS ? Infinity : values[a] / weights[a]; + const rb = weights[b] <= EPS ? Infinity : values[b] / weights[b]; + return rb - ra; + }); + const caps = [0.0, 0.0, 0.0]; + + let best = 0.0; + const maxEngine = canHub ? buildableTiles : 0; + + for (let nEngine = 0; nEngine <= maxEngine; nEngine++) { + const minShoreEngine = Math.max(0, nEngine - inlandTiles); + const maxShoreEngine = Math.min(shoreTiles, nEngine); + for ( + let shoreEngine = minShoreEngine; + shoreEngine <= maxShoreEngine; + shoreEngine++ + ) { + const heatCap = bestHeatForMixedEngineTiles( + nEngine, + shoreEngine, + inland, + shore, + ); + // Engine tiles that can't form a reactor+generator pair. + if (nEngine > 0 && heatCap <= 0.0) continue; + + const inlandRest = inlandTiles - (nEngine - shoreEngine); + const shoreRest = shoreTiles - shoreEngine; + caps[0] = heatCap; + caps[1] = canDp ? inlandRest : 0; + caps[2] = canDp ? shoreRest : 0; + + let cooling = inland.cVal * inlandRest + shore.cVal * shoreRest; + let power = 0.0; + for (const item of order) { + const cap = caps[item]; + if (cap <= 0 || values[item] <= 0) continue; + if (weights[item] <= EPS) { + power += values[item] * cap; + continue; + } + const taken = Math.min(cap, cooling / weights[item]); + if (taken <= 0) continue; + power += values[item] * taken; + cooling -= taken * weights[item]; + } + + if (power > best) best = power; + } + } + + return best; +} + +/** + * The roster as a rule that scales a **whole role** rates it, so the bound + * below can be computed on the figures the layout actually runs under rather + * than scaled up afterwards. + * + * A role scale is uniform across the island — every cooler under a shared pool + * is x0.88 wherever it stands, and a search free to keep generators apart rates + * every one of them at the isolation bonus — so it is a property of the roster + * and belongs in the roster. Running the LP on that roster is a tighter bound + * than scaling its result by the largest factor: `islandMaxScale` had to scale + * the *whole* estimate by 2.5 under Singularity, which rated reactors and + * coolers up as well and left the bound 2.3x the tight one, so a near-optimal + * layout read as 40% layout efficiency. Scaling only the generator entries is + * sound for the same reason as before — any layout under the rule is feasible + * in this roster with an objective no larger — and it is the roster the bound + * is entitled to assume. + * + * A role isolation takes the larger of its two variants, because which one a + * tile gets is a function of the layout rather than of the island, and the + * penalty is the one usually binding while the bonus is the one a layout may + * reach. A terrain bonus is a property of the tile, not the role, and is not + * answered here — see `islandMaxScale`. + * + * Exhaustive without a `default`, like `islandMaxScale` and for the same + * reason: a fifth rule shape fails to typecheck here rather than silently + * leaving the roster plain under a rule that rates something above it. + */ +function roleRatedRoster( + roster: EffectiveBuilding[], + anomaly?: AnomalyDefinition, +): EffectiveBuilding[] { + if (anomaly === undefined) return roster; + + switch (anomaly.rule) { + case "baseline": + case "terrain_affinity": + return roster; + + case "shared_cooling": + return roster.map((b) => + b.type === "cooler" + ? scaleEffectiveBuilding(b, anomaly.coolerMultiplier) + : b, + ); + + case "role_isolation": { + const factor = Math.max(anomaly.isolated, anomaly.crowded); + return roster.map((b) => + b.type === anomaly.role ? scaleEffectiveBuilding(b, factor) : b, + ); + } + } +} + +/** + * How many of the island's tiles a terrain bonus rates, or `null` when the + * shore mask cannot say. + * + * Water is the one terrain that can lie *off* the board, and the shore mask is + * the only thing that knows it — so the count is exact when water is the whole + * of the list. A list naming anything else qualifies a tile on a neighbour this + * window can see but the mask cannot speak for (`terrainScales` tests rock and + * tree against the grid), and one that mixed the two would need both answers + * at once. `null` is the honest answer there, and the bound errs high on it: a + * landlocked island under a `["water", "rock"]` anomaly would otherwise rate + * its tiles at the multiplier while the bound stayed at 1. + */ +function ratedTileCount( + island: IslandSubGrid, + anomaly: TerrainAffinityAnomaly, +): number | null { + if (anomaly.terrain.length !== 1 || anomaly.terrain[0] !== "water") + return null; + + let count = 0; + for (let i = 0; i < island.buildable.length; i++) { + if (island.buildable[i] === 1 && island.waterAdjacent[i] === 1) count++; + } + return count; +} + +/** + * The largest multiplier a **tile** on this island can rate a building at, + * beyond what `roleRatedRoster` has already put into the roster. * * One number for the island rather than one per tile, because it is used to * keep the bound a bound: `estimateIslandMaxPower` is positively homogeneous of @@ -464,17 +752,13 @@ function bestHeatForEngineTiles( * whole island at its best tile is an upper bound on rating each tile at its * own. * - * That argument is what lets a rule that scales only *some* buildings be - * answered with one island-wide number: any layout feasible under the partial - * scaling is feasible in the all-scaled world with an objective no larger. - * Under generator-only scaling, say, `H <= min(nReact * rVal, nGen * k * gVal) - * <= k * heatCap`, the cooling constraint at `k * dpWaste` is implied by the - * real one since `H / k <= H`, and the objective at `k * dpNet` dominates. - * - * It is loose by construction where only part of an island qualifies, or where - * only part of the roster is scaled, and that is the right trade: a bound that - * can be beaten is worthless, while one that is generous only makes the - * efficiency figure read low. + * It is loose by construction where only part of an island qualifies, and that + * is the right trade where nothing better can be said: a bound that can be + * beaten is worthless, while one that is generous only makes the efficiency + * figure read low. Where the mask *can* say which tiles carry the factor, + * `estimateIslandBound` goes on to the two-class bound and this is its + * ceiling. The role-shaped rules answer 1 here because their factor is already + * in the roster, where it is exact rather than loose. * * The switch is exhaustive without a `default`, deliberately: a fifth rule shape * added to `AnomalyDefinition` then fails to typecheck here — "function lacks @@ -489,55 +773,65 @@ function islandMaxScale( switch (anomaly.rule) { case "baseline": - return 1; - case "shared_cooling": - // A scale on the cooler role alone. The shipped 0.88 only ever *costs* - // cooling, so this is 1 today and the bound is untouched; a pool that - // paid more than it took would raise the cooling budget, and by the - // homogeneity above that is bounded by scaling the whole roster. - return Math.max(anomaly.coolerMultiplier, 1); - case "role_isolation": - // Both variants, because which one a tile gets is a function of the - // layout rather than of the island: `simulateIsland` re-rates a generator - // from what its neighbours are, so a search free to keep generators apart - // rates every one of them at `isolated`. The bound has to allow that even - // though it is the *penalty* that is usually binding — a roster whose - // generator intake is the short side of `min(nReact * rVal, nGen * gVal)` - // gets x2.5 of its heat cap, and a 3x3 layout beat the "TRUE upper bound" - // by 64% before this case existed. - return Math.max(anomaly.isolated, anomaly.crowded, 1); + // Uniform across the island, so carried by `roleRatedRoster` instead. + return 1; case "terrain_affinity": { if (anomaly.multiplier <= 1) return 1; - // Water is the one terrain that can lie *off* the board, and the shore - // mask is the only thing that knows it — so the scan below answers the - // question exactly when water is the whole of the list. A list naming - // anything else qualifies a tile on a neighbour this window can see but - // the mask cannot speak for (`terrainScales` tests rock and tree against - // the grid), and one that mixed the two would need both answers at once. - // Erring high there keeps this a bound; a landlocked island under a - // `["water", "rock"]` anomaly would otherwise rate its tiles at the - // multiplier while the bound stayed at 1. - if (anomaly.terrain.length !== 1 || anomaly.terrain[0] !== "water") - return anomaly.multiplier; - - for (let i = 0; i < island.buildable.length; i++) { - if (island.buildable[i] === 1 && island.waterAdjacent[i] === 1) - return anomaly.multiplier; - } - return 1; + const rated = ratedTileCount(island, anomaly); + return rated === null || rated > 0 ? anomaly.multiplier : 1; } } } +/** + * One island's bound under the rules in force, on a roster `roleRatedRoster` + * has already rated. + * + * Three answers, from loose to tight. An island no tile rule reaches is the + * plain LP. One the mask cannot decide, or every tile of which qualifies, is + * the plain LP at `islandMaxScale` — for an all-shore island that *is* the + * two-class figure, and it is spelled this way so the figure is the one it + * always was. A mixed island is the two-class LP, capped by the whole-island + * figure: both are bounds, so the smaller is, and the cap is what keeps an + * island of two or three tiles — where a fractional cooler is worth more than + * a whole one — from reading looser than it did before the split existed. + */ +function estimateIslandBound( + island: IslandSubGrid, + roster: EffectiveBuilding[], + anomaly?: AnomalyDefinition, +): number { + const plain = estimateIslandMaxPower(island, roster); + const scale = islandMaxScale(island, anomaly); + if (scale === 1) return plain; + + // Only a terrain rule answers above 1, and only a water-only list has a mask + // that can say which tiles carry it. + const rated = + anomaly?.rule === "terrain_affinity" + ? ratedTileCount(island, anomaly) + : null; + if (rated === null || rated >= island.tileCount) return plain * scale; + + return Math.min( + plain * scale, + estimateMixedIslandMaxPower(island.tileCount, rated, roster, scale), + ); +} + /** * Sum of theoretical max power across all valid island sub-grids. * * `anomaly` is taken because the bound has to hold under the rules the search * is actually running: a terrain bonus rates some tiles above their authored * figures, and a bound computed on the plain roster is one a real layout beats. + * A rule that scales a whole role goes into the roster (`roleRatedRoster`), + * where the bound is exact in it; one that scales a tile splits the island's + * tiles by class (`estimateIslandBound`), and scales the result only where the + * mask cannot say which tiles it reaches. * The "layout efficiency" figure this feeds then reads above 100%, which is the * visible half of the problem; the invisible half is that nothing else in the * solver is entitled to assume the bound holds either. @@ -547,9 +841,9 @@ export function estimateTotalMaxPower( roster: EffectiveBuilding[], anomaly?: AnomalyDefinition, ): number { + const rated = roleRatedRoster(roster, anomaly); let total = 0; for (const island of islands) - total += - estimateIslandMaxPower(island, roster) * islandMaxScale(island, anomaly); + total += estimateIslandBound(island, rated, anomaly); return total; } diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index 796e39b..ddede39 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -71,6 +71,15 @@ import type { 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; @@ -317,6 +326,8 @@ interface HubFit { r: number; c: number; power: number; + /** The waste the hub makes at that fill — what a pooled seed charges for. */ + waste: number; } function bestHubFit( @@ -346,7 +357,7 @@ function bestHubFit( if (r + c <= availableSlots) { const power = conversion.power; if (best === null || power > best.power) { - best = { generator, reactor, r, c, power }; + best = { generator, reactor, r, c, power, waste }; } } if (hIn >= generator.effectiveValue - EPS) break; @@ -360,6 +371,7 @@ interface DpFit { dp: EffectiveBuilding; c: number; power: number; + waste: number; } function bestDpFit( @@ -374,7 +386,7 @@ function bestDpFit( 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 }; + if (best === null || power > best.power) best = { dp, c, power, waste }; } return best; } @@ -404,6 +416,7 @@ interface SharedFit { reactorTiers: EffectiveBuilding[]; genPositions: number[]; coolerPositions: number[]; + waste: number; } /** @@ -420,11 +433,29 @@ function bestSharedReactorFit( 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; @@ -509,6 +540,10 @@ function bestSharedReactorFit( 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; @@ -530,6 +565,7 @@ function bestSharedReactorFit( reactorTiers, genPositions, coolerPositions, + waste: wasteTotal, }; } } @@ -567,6 +603,7 @@ function constructSeed( coolers: EffectiveBuilding[], directProducers: EffectiveBuilding[], deadlineMs: number, + pooled = false, ): Placement { const placement = emptyPlacement(ctx.n); const available = new Uint8Array(ctx.n); @@ -583,6 +620,40 @@ function constructSeed( 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); @@ -590,7 +661,7 @@ function constructSeed( const hubFit = (slots: number): HubFit | null => { let fit = hubFitCache.get(slots); if (fit === undefined) { - fit = canHub ? bestHubFit(slots, generators, reactors, topCooler!) : null; + fit = canHub ? bestHubFit(slots, generators, reactors, fitCooler!) : null; hubFitCache.set(slots, fit); } return fit; @@ -599,7 +670,7 @@ function constructSeed( const dpFit = (slots: number): DpFit | null => { let fit = dpFitCache.get(slots); if (fit === undefined) { - fit = canDp ? bestDpFit(slots, directProducers, topCooler!) : null; + fit = canDp ? bestDpFit(slots, directProducers, fitCooler!) : null; dpFitCache.set(slots, fit); } return fit; @@ -623,12 +694,17 @@ function constructSeed( entry = candidate; }; - const hub = hubFit(neighbors.length); + // 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 / tilesUsed, + powerPerTile: hub.power / cost(tilesUsed, hub.waste), kind: "hub", tile, neighbors, @@ -638,11 +714,11 @@ function constructSeed( } const dp = dpFit(neighbors.length); - if (dp !== null) { + if (dp !== null && reserves(1, dp.waste)) { const tilesUsed = 1 + dp.c; if (dp.power > EPS && tilesUsed > 0) { consider({ - powerPerTile: dp.power / tilesUsed, + powerPerTile: dp.power / cost(tilesUsed, dp.waste), kind: "dp", tile, neighbors, @@ -657,18 +733,19 @@ function constructSeed( neighbors, reactors, topGenerator!, - topCooler!, + fitCooler!, ctx, sharedScratch, + pooled, ); if (shared !== null) { const tilesUsed = shared.reactorTiles.length + shared.genPositions.length + shared.coolerPositions.length; - if (tilesUsed > 0) { + if (tilesUsed > 0 && reserves(tilesUsed, shared.waste)) { consider({ - powerPerTile: shared.power / tilesUsed, + powerPerTile: shared.power / cost(tilesUsed, shared.waste), kind: "shared", tile, neighbors, @@ -696,6 +773,29 @@ function constructSeed( } 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!; @@ -746,6 +846,53 @@ function constructSeed( 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; @@ -761,71 +908,90 @@ function constructMultiStartSeed( ): Placement { const buildable = Array.from(ctx.tiles); - let bestSeed = constructSeed( - buildable, - ctx, - reactors, - generators, - coolers, - directProducers, - deadlineMs, - ); - 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( + const passes = (pooled: boolean): { seed: Placement; power: number } => { + let bestSeed = constructSeed( buildable, ctx, reactors, generators, coolers, directProducers, - performance.now() + 1000, + deadlineMs, + pooled, ); - bestPower = simulateIsland(bestSeed, ctx).totalPower; - } + 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 bestSeed; + 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, - ); - 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, + const reversed = [...buildable].reverse(); + const seedRev = constructSeed( + reversed, ctx, reactors, generators, coolers, directProducers, deadlineMs, + pooled, ); - const powerRand = simulateIsland(seedRand, ctx).totalPower; - if (powerRand > bestPower + EPS) bestSeed = seedRand; - } + 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; - return bestSeed; + // 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; } // --------------------------------------------------------------------------- @@ -954,10 +1120,11 @@ async function hillClimb( // // 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 x1.67 shore - // is worth x1.67 of one here. Left alone deliberately: the previous, hotter - // calibration was itself the bug above, and nothing in this repo moves a tuning - // constant on an argument rather than a measurement. Whoever measures it should - // measure across several seeds and both anomalies. + // is worth x1.67 of one here. Scaling it by `islandRatingCeiling` was measured + // at 15s over three seeds: Tidal on map 3 went 176/178/179AC to 179/181/178AC, + // Singularity on map 3 142/142/139AC to 140/142/142AC and on map 7 271/271/271AC + // to 268/267/271AC — 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]) @@ -1039,6 +1206,13 @@ async function hillClimb( 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]; @@ -1251,8 +1425,9 @@ function targetCompositions( * 1.49e23, so the stage never ran at any realistic budget. * * Scaling the target by one island-wide factor is the move `estimateTotalMaxPower` - * already makes to keep its bound a bound (`islandMaxScale`), and it is sound for - * the same reason: a composition's power is positively homogeneous of degree 1 in + * 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 @@ -2056,17 +2231,27 @@ export async function solveIsland( ); if (ctx.n === 0) return { placements: [], powerOutput: 0.0 }; + // The pools every stage draws on, in the units the board will hold: a rule + // that scales a whole role (a cooling pool's x0.88) is folded in here, so the + // stages that count from the roster rather than place on a tile count the + // right figure. `rate` is idempotent, so a pool entry that already carries + // its role's rating is handed straight back at the write. + const pool = (b: EffectiveBuilding) => ctx.rateRole(b); const reactors = effectiveBuildings .filter(isReactor) + .map(pool) .sort((a, b) => b.effectiveValue - a.effectiveValue); const generators = effectiveBuildings .filter((b) => b.type === "generator") + .map(pool) .sort((a, b) => b.effectiveValue - a.effectiveValue); const coolers = effectiveBuildings .filter((b) => b.type === "cooler") + .map(pool) .sort((a, b) => b.effectiveValue - a.effectiveValue); const directProducers = effectiveBuildings .filter(isDirectProducer) + .map(pool) .sort((a, b) => b.energy - a.energy); const canHub = reactors.length > 0 && generators.length > 0; diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts index 6f0d64d..770de49 100644 --- a/packages/solver/tests/anomalies.test.ts +++ b/packages/solver/tests/anomalies.test.ts @@ -32,8 +32,8 @@ import { estimateTotalMaxPower, splitGridIntoIslands, } from "../src/solver/island"; -import { solveIsland } from "../src/solver/placementSearch"; -import { basicCatalogue, basicRoster, cooler } from "./helpers"; +import { internals, solveIsland } from "../src/solver/placementSearch"; +import { basicCatalogue, basicRoster, cooler, reactor } from "./helpers"; import type { AnomalyDefinition, EffectiveBuilding, @@ -262,6 +262,95 @@ describe("a shared cooling pool reaching the board", () => { }); }); +describe("the seed under a shared cooling pool", () => { + /* + * Under a pool a cooler's position contributes nothing but the tile it + * stands on, so a seed that spends a hub's own neighbours on coolers is + * spending the best-connected tiles on the one building that needs none — + * and it never fills a scrap, because no hub fits there, though a cooler on + * one is pure gain. The pooled seed builds every hub engine-first and pays + * for its cooling on the least-connected free tiles. + * + * A 4x4 block beside three scraps: one tile on its own and a pair. Two-tile + * hubs (one reactor fills one generator) fit the block exactly eight times, + * and the waste those make needs three coolers at x0.88 — the three scraps. + */ + const BOARD = ["GGGGRG", "GGGGRR", "GGGGRG", "GGGGRG"]; + const roster = basicRoster(); + const [REACTOR, GENERATOR, COOLER] = roster; + + const seedFor = (anomaly: AnomalyDefinition) => { + const ctx = buildIslandContext(makeGrid(BOARD), undefined, anomaly); + const pool = (b: EffectiveBuilding) => ctx.rateRole(b); + const seed = internals.constructMultiStartSeed( + ctx, + [pool(REACTOR)], + [pool(GENERATOR)], + [pool(COOLER)], + [], + performance.now() + 2_000, + ); + const isScrap = (t: number) => ctx.xs[t] === 5; + const coolers: number[] = []; + const besideGenerator: number[] = []; + for (let t = 0; t < ctx.n; t++) { + if (seed[t]?.type !== "cooler") continue; + coolers.push(t); + const nb = ctx.neighbors[t]; + for (let k = 0; k < nb.length; k++) { + if (seed[nb[k]]?.type === "generator") { + besideGenerator.push(t); + break; + } + } + } + return { + ctx, + seed, + power: simulateIsland(seed, ctx).totalPower, + coolers, + onScraps: coolers.filter(isScrap), + besideGenerator, + emptyScraps: Array.from(ctx.tiles).filter( + (t) => isScrap(t) && seed[t] === null, + ), + }; + }; + + it("puts no cooler beside a hub while a less-connected tile is free", () => { + const out = seedFor(getAnomaly("cryo_nexus")); + + // Alive: a short pool is an all-offline board, and `stabilize` would then + // delete every producer the seed placed. + expect(out.power).toBeGreaterThan(EPS); + expect(out.coolers.length).toBeGreaterThan(0); + // Every cooler on a scrap, so none beside a generator — the block is all + // engine. + expect(out.onScraps.length).toBe(out.coolers.length); + expect(out.besideGenerator).toEqual([]); + expect(out.emptyScraps).toEqual([]); + // And the pool it bought covers the waste it makes, with nothing spare: + // the block holds eight hubs and the scraps their three coolers. + expect(out.coolers.length).toBe(3); + expect(out.power).toBeCloseTo(8 * GENERATOR.energy, 6); + }); + + it("is a branch taken only under the pool", () => { + /* + * The same board under the base rules keeps the seed the fixtures pin: + * coolers beside the hubs they serve, and the scraps — where no hub fits — + * left empty. This is what makes the case above non-vacuous, and it is the + * base path's byte-identity stated at the one board where the two differ. + */ + const out = seedFor(getAnomaly("none")); + + expect(out.power).toBeGreaterThan(EPS); + expect(out.besideGenerator.length).toBeGreaterThan(0); + expect(out.onScraps).toEqual([]); + expect(out.emptyScraps.length).toBe(3); + }); +}); + describe("role isolation reaching the board", () => { /* * The one rule whose multiplier depends on the layout rather than on the @@ -510,6 +599,37 @@ describe("a terrain bonus reaching the board", () => { expect(ctx.rate(ctx.tiles[1], base)).toBe(ctx.rate(ctx.tiles[0], base)); }); + it("hands the counting stages the role's rating, with no tile to ask about", () => { + /* + * `rateRole` is the per-role half of `rate` on its own. The stages that + * count from the roster rather than place on a tile — hub fits, the + * composition targets — reason from it, so under a pool they count a + * cooler at what the board will hold. Counted from the plain figure, every + * target the retarget stage proposed under Cryo was 8-14% short of the + * waste it planned to make, which under a pool is a board that is offline + * entirely — so the stage never produced a layout that could beat the one + * in hand, and did so silently. + */ + const pooled = contextFor(["GGGGG", "GGGGG"], getAnomaly("cryo_nexus")); + const base = cooler(100); + const rated = pooled.rateRole(base); + + expect(rated.effectiveValue).toBeCloseTo(88, 9); + // The same object `rate` hands out, so a pool entry written onto a tile is + // handed straight back rather than scaled a second time. + expect(pooled.rate(pooled.tiles[0], rated)).toBe(rated); + expect(pooled.rate(pooled.tiles[0], base)).toBe(rated); + // Only the role the rule names. + expect(pooled.rateRole(reactor(100))).toEqual(reactor(100)); + + // The identity wherever no rule scales a whole role, a terrain bonus + // included: that one is a property of the tile, not of the role. + for (const id of ["none", "singularity_isolation", "tidal_ascendancy"]) { + const ctx = contextFor(["GGGGG", "GGGGG"], getAnomaly(id)); + expect(ctx.rateRole(base), id).toBe(base); + } + }); + it("reports a power the same layout re-rates to", async () => { /* * The agreement between the solver and everything that reads its output. diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index faa5cdd..2bbf97a 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -16,6 +16,11 @@ import { EPS } from "../src/solver/constants"; import { buildIslandContext } from "../src/solver/context"; import { makeGrid } from "../src/grid"; import { getAnomaly } from "../src/data/anomalies"; +import { BUILDINGS, allUpgradesUnlocked } from "../src/data/buildings"; +import { + getEffectiveBuildings, + scaleEffectiveBuilding, +} from "../src/data/effectiveBuildings"; import { ISLAND_TEMPLATES } from "../src/data/maps"; import { decodeBlueprint } from "../src/encoding/blueprint"; import { @@ -532,9 +537,11 @@ describe("the theoretical max-power bound", () => { * A CLI run on Magma Rift reported 119.9% layout efficiency before the * bound learned about the anomaly. * - * The board is bare grass, so every tile of it is on the board's edge and - * every building is bonused — the worst case for the bound, and the one - * where being loose is no excuse. + * The board is bare grass, so every tile but the centre is on the board's + * edge and eight of the nine buildings are bonused — close to the worst + * case for the bound, and the one where being loose is no excuse. The + * mixed-board case, where the two-class bound has real work to do, is + * `the two-class bound under a terrain bonus` below. */ const tidal = getAnomaly("tidal_ascendancy"); const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); @@ -623,8 +630,9 @@ describe("the theoretical max-power bound", () => { * adjacency — a cooler on the far side of the board now cools everything — * and the bound is safe because it never assumed any: it relaxes adjacency * away and asks only what the tile counts allow. The 0.88 on every cooler - * only ever costs cooling, so the island scale stays 1 and the bound is the - * plain one. + * goes into the roster the bound is run on, so it is the plain bound over a + * roster whose coolers are worth 0.88 of their tier — below the plain one, + * never above it. * * The board is handed over whole here, as `wholeBoardIsland` does, because * that is what the pool is defined over. @@ -644,7 +652,18 @@ describe("the theoretical max-power bound", () => { island.waterAdjacent, ); - expectClose(bound, boundFor(island, roster)); + expectClose( + bound, + boundFor( + island, + roster.map((b) => + b.type === "cooler" ? scaleEffectiveBuilding(b, 0.88) : b, + ), + ), + ); + // Equal here rather than below: this roster is bound by its direct + // producers, not by cooling, so the rated coolers move nothing. + expect(bound).toBeLessThanOrEqual(boundFor(island, roster)); const rng = new Rng(20260921); const options: (EffectiveBuilding | null)[] = [...roster, null]; @@ -666,9 +685,13 @@ describe("the theoretical max-power bound", () => { it("rises with a terrain bonus only where a tile qualifies", () => { const tidal = getAnomaly("tidal_ascendancy"); const roster = basicRoster(); - // Bare grass: every tile is on the board's edge, so the whole island is - // shore and the bound is the plain one scaled by the multiplier. - const shore = islandFor(["GGG", "GGG", "GGG"], roster); + // Two rows of bare grass: every tile is on the board's edge, so the whole + // island is shore and the bound is the plain one scaled by the multiplier + // — the two-class figure has one class, and it is spelled as the figure it + // always was. (A bare 3x3 is *not* this: its centre tile has all eight + // neighbours on the board, and that one inland tile is enough for the + // two-class bound to come in under the scaled one.) + const shore = islandFor(["GGGG", "GGGG"], roster); // Walled in and away from every edge: nothing qualifies, so the bound is // untouched and stays as tight as it was. const inland = islandFor( @@ -686,6 +709,177 @@ describe("the theoretical max-power bound", () => { ); }); + describe("the two-class bound under a terrain bonus", () => { + /* + * Rating a whole island at its best tile is a bound, and on the shipped + * maps it is a quarter loose: the coast is 36-44% of the grass, and the + * scaled figure rates every inland tile as if it stood on it. The + * two-class bound splits the tile budget by class instead — a shore + * building is worth its shore figures, an inland one its plain ones, and + * both pay into the one heat and the one cooling total. These pin that it + * is still a bound, that it is tighter, and that it collapses to the old + * figure at either end. + */ + const tidal = getAnomaly("tidal_ascendancy"); + + /** The figure the two-class bound replaced: the plain LP at the multiplier. */ + const wholeIslandFigure = ( + island: IslandSubGrid, + roster: EffectiveBuilding[], + ): number => estimateTotalMaxPower([island], roster) * 1.67; + + it("is never beaten by a random layout on a mixed board", () => { + /* + * A 5x5 of bare grass: a ring of sixteen shore tiles round nine inland + * ones, so a random layout straddles the coast every time. The roster + * has a direct producer because the producer/cooler split is the one + * part of the bound that is relaxed to fractions, and a board with no + * producers would never exercise it. + */ + const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); + const island = islandFor( + ["GGGGG", "GGGGG", "GGGGG", "GGGGG", "GGGGG"], + roster, + ); + const bound = estimateTotalMaxPower([island], roster, tidal); + expect(bound).toBeLessThan(wholeIslandFigure(island, roster)); + + const ctx = buildIslandContext( + island.grid, + island.buildable, + tidal, + island.waterAdjacent, + ); + let shore = 0; + let inland = 0; + for (let t = 0; t < ctx.n; t++) { + if (ctx.rate(t, roster[0]) === roster[0]) inland++; + else shore++; + } + expect([shore, inland], "the board is mixed").toEqual([16, 9]); + + const rng = new Rng(20260922); + const options: (EffectiveBuilding | null)[] = [...roster, null]; + for (let attempt = 0; attempt < 500; attempt++) { + const placement: Placement = Array.from({ length: ctx.n }, (_, t) => { + const pick = rng.choice(options); + return pick === null ? null : ctx.rate(t, pick); + }); + + const { totalPower } = simulateIsland(placement, ctx); + + expect( + totalPower, + `a layout beat the 'upper' bound on attempt ${attempt}`, + ).toBeLessThanOrEqual(bound + EPS); + } + }); + + it("is not beaten by a layout built to the bound's own shape", () => { + /* + * Random placement rarely comes near the bound, so this layout is built + * by hand to lean the way the LP's optimum leans — every reactor and + * generator on the coast, the coolers inland, no cluster straddling the + * shoreline — which is the shape a real search converges on. A bound + * this layout beat would be one the search beats too. + * + * R G R G the top and bottom rows and the two ends are shore; + * G C C G the inner 2x2 is inland and takes the coolers. The + * R G R G generators straddle nothing: each touches a shore + * reactor and an inland cooler. + */ + const roster = basicRoster({ reactorValue: 100, generatorValue: 50 }); + const island = islandFor(["GGGG", "GGGG", "GGGG"], roster); + const bound = estimateTotalMaxPower([island], roster, tidal); + expect(bound).toBeLessThan(wholeIslandFigure(island, roster)); + + const ctx = buildIslandContext( + island.grid, + island.buildable, + tidal, + island.waterAdjacent, + ); + const shape = placeOn(ctx, ["RGRG", "GCCG", "RGRG"], { + R: roster[0], + G: roster[1], + C: roster[2], + }); + const placement: Placement = shape.map((b, t) => + b === null ? null : ctx.rate(t, b), + ); + + const { totalPower } = simulateIsland(placement, ctx); + expect(totalPower).toBeLessThanOrEqual(bound + EPS); + expect(totalPower).toBeGreaterThan(0); + }); + + 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. + const nearlyShore = islandFor(["GGG", "GGG", "GGG"], roster); + const half = islandFor( + ["RRRRRRR", "RGGGGGR", "RGGGGGR", "RGGGGGR", "R.....R"], + roster, + ); + + for (const island of [nearlyShore, half]) { + const bound = estimateTotalMaxPower([island], roster, tidal); + expect(bound).toBeLessThan(wholeIslandFigure(island, roster)); + // And never below what the plain rules allow: a bonus cannot make a + // layout worth less than it is without one. + expect(bound).toBeGreaterThanOrEqual( + estimateTotalMaxPower([island], roster), + ); + } + }); + + it("never reads looser than the whole-island figure, however small the island", () => { + /* + * The producer/cooler split is fractional, and on an island of a few + * tiles a fractional cooler is worth more than a whole one — enough to + * put the two-class figure *above* the whole-island one it exists to + * tighten. The smaller of the two is taken, so the bound is never worse + * than it was. + */ + const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); + // Three grass tiles walled in by rock, one of them beside water: two + // inland, one shore. + const island = islandFor(["RRRRR", "RGGG.", "RRRRR"], roster); + + expect(estimateTotalMaxPower([island], roster, tidal)).toBeLessThanOrEqual( + wholeIslandFigure(island, roster) + EPS, + ); + }); + + it("tightens the shipped maps by about a quarter", async () => { + /* + * The measurement that justified the construction: at full unlocks the + * two-class bound sits at 0.758-0.771 of the whole-island figure on + * every shipped map, and Magma Rift's best 15s layout reads 93% of it + * where it read 72% before. Pinned against the codes the app ships, so a + * re-authored map that changed its coastline would move this on purpose. + */ + const roster = getEffectiveBuildings(BUILDINGS, allUpgradesUnlocked()); + for (const template of ISLAND_TEMPLATES) { + const { grid } = await decodeBlueprint(template.code); + const islands = splitGridIntoIslands( + grid, + canCoolDirectProducer(roster), + tidal, + ); + const bound = estimateTotalMaxPower(islands, roster, tidal); + const whole = islands.reduce( + (sum, island) => sum + wholeIslandFigure(island, roster), + 0, + ); + const base = estimateTotalMaxPower(islands, roster); + + expect(bound, template.id).toBeLessThanOrEqual(0.8 * whole); + expect(bound, template.id).toBeGreaterThanOrEqual(base); + } + }); + }); + it("errs high for a terrain list the shore mask cannot decide", () => { /* * The shore mask is the only thing that knows off-board counts as water, so From 2fd2af271dbdf07168e0b2a8864dc03bbe1a0c6d Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:40:26 +0300 Subject: [PATCH 12/21] Say which timeline a layout was searched under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the anomaly with a solve standing re-rates it and says nothing. `rescoreResult` re-reads every variant's figures under the new rules without searching again, which is the right call — that layout was chosen for the old roster and may no longer be the best shape, or a stable one, so re-running is the player's decision. But the figures it prints afterwards are honest about a board whose *shape* belongs to a timeline nobody is in any more, and nothing on screen distinguished that from a solve found under the rules now in force. `solverState.resultAnomalyId` is the anomaly the shortlist was actually searched under, and its whole value is that it may disagree with the selection. It deliberately does not follow `rescoreResult`: an earlier pass moved it to the current anomaly there, which erased the warning it exists to raise and let Run's "keep the better one?" dialog defend a layout that had never been searched under the new rules at all. It is captured at launch instead (`#runAnomalyId`, beside the island and the signature a run already pins), so moving the selection mid-run cannot change what the search returns, and `AnomalySelector` disables its cards while one is in flight for the same reason — the search reads the anomaly once and a press there could only silently retarget the next run. The readout grows an Anomaly row naming the current selection, with a `--warn` note beside it — not `--status-idle` or `--danger`, which the colour law reserves for what a building on the board is doing — on the one reading where the layout was searched under something else. Run skips the re-run dialog across that gap, since a layout found under other rules is not a bar a fresh run has to clear; the comparison is against `activeAnomaly.id` rather than the raw stored id, which is whatever localStorage held, or an unrecognised string would never match a resolved one and the dialog would be skipped for good. `SavedSolveData` carries the anomaly so a reload still knows. That is not the same fact as the one inside `signature`: re-scoring re-files a record under the new signature without searching, so the signature says what it was last rated against and this says what it was found under. A record written before the field has none, and a matching signature already settles it there. The purple an anomaly puts on Setup, the readout and Run now reaches the rest of the chrome — the header, the overflow menu, every dialog through `ModalShell`, both palettes, the mode row, the Undo/Redo/Run and Edit/Solver pills, and the size steppers. Each re-points the inherited tokens rather than restyling anything, so nothing new learns that anomalies exist; `--neon` goes with them only inside the dialogs and the steppers, which read it directly for a title or a button where Setup's own ground would otherwise have carried it. `--text-dim` travels with the ground wherever a label that is not a disabled control sits on it, since it is tuned to clear AA on the navy panel and does not on this one. One thing that is not about anomalies at all: both palettes were pill-radiused around boxy items, and a pill's corner tracks half its height, so at ~70px the outer curve swept well inside the `--radius-md` items and clipped their corners. Both take `--radius-lg` at every width now, which is what the phone breakpoint had already been doing for the same reason. --- src/lib/components/header/GridControls.svelte | 28 ++++++- src/lib/components/header/Header.svelte | 23 +++++- src/lib/components/header/OverflowMenu.svelte | 31 +++++++- src/lib/components/hud/BoardActions.svelte | 15 +++- src/lib/components/hud/BuildingPalette.svelte | 30 +++++++- src/lib/components/hud/HudToolbar.svelte | 44 ++++++++++- src/lib/components/hud/ObstaclePalette.svelte | 17 ++++- .../components/hud/PlacementViewToggle.svelte | 22 +++++- .../inspector/BoardStatsCard.svelte | 73 +++++++++++++++++++ src/lib/components/modals/ModalShell.svelte | 27 +++++++ .../components/sidebar/AnomalySelector.svelte | 21 +++++- src/lib/state/solver.svelte.ts | 70 ++++++++++++++++-- src/lib/state/ui.svelte.ts | 16 +++- src/lib/storage/storage.ts | 14 ++++ 14 files changed, 405 insertions(+), 26 deletions(-) diff --git a/src/lib/components/header/GridControls.svelte b/src/lib/components/header/GridControls.svelte index 5a8381d..e76baae 100755 --- a/src/lib/components/header/GridControls.svelte +++ b/src/lib/components/header/GridControls.svelte @@ -1,6 +1,6 @@ -
+
+ {#if showAnomalyRow} + +
+ + {#if anomalyStale}{/if} + Anomaly + + + {selectedAnomaly.name} + {#if anomalyStale} + (solved under {resultAnomaly?.name}) + {/if} + +
+ {/if} + {#if cycling} @@ -140,10 +152,15 @@ background var(--dur-fast) var(--ease); } - .anomaly-card:hover { + .anomaly-card:hover:not(:disabled) { border-color: var(--anomaly-selected-glow); } + .anomaly-card:disabled { + opacity: 0.5; + cursor: default; + } + /* * Green, not `--neon`, and only here — see the `--anomaly-selected` note in * `app.css`. The ring is the game's own, so the card the player chose a diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index 0ef011a..76558c3 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -1,4 +1,5 @@ import type { OptimizationResult, PlacedBuilding } from "../types"; +import { DEFAULT_ANOMALY_ID, type AnomalyId } from "@reactor2/solver"; import { SolverWorkerClient, type SolveTaskHandle, @@ -228,6 +229,21 @@ class SolverState { /** True when the on-screen result came from storage rather than this session. */ isRestored = $state(false); + /** + * The anomaly the on-screen shortlist was actually *searched* under. + * + * Not `configState.activeAnomaly.id`, and the gap between the two is the + * whole point of the field. `rescoreResult()` re-rates every variant's + * figures under a newly selected anomaly but never searches again, so the + * shapes on screen go on being the ones some other set of rules produced — + * this is what still says which. A run in flight sets it to `#runAnomalyId` + * for the same reason: the search reads the anomaly once, at launch, so + * moving the selection while one is running cannot change what it returns. + * + * Null until a result exists at all. + */ + resultAnomalyId = $state(null); + /** * How the next run spends its time: one long search, or several short ones. * @@ -243,16 +259,20 @@ class SolverState { #ticker: ReturnType | null = null; /** - * The island a run was started on, and the board it was started against. + * The island a run was started on, the board it was started against, and + * the rules it was started under. * * A solve belongs to the island it was launched from, not to whatever is on - * screen when it lands: the user may switch away mid-run. Both are captured - * at the start so the result is filed under the right island with the right - * signature, and so a late progress report cannot paint itself over a - * different board. + * screen when it lands: the user may switch away mid-run. All three are + * captured at the start so the result is filed under the right island with + * the right signature, so a late progress report cannot paint itself over a + * different board, and so every stage of the run — the request, the streamed + * layouts, the record — names the one anomaly it was actually searched + * under. */ #runTemplateId = ""; #runSignature = ""; + #runAnomalyId: AnomalyId = DEFAULT_ANOMALY_ID; #client = new SolverWorkerClient({ reportIntervalMs: 1000, // 1 second @@ -468,6 +488,12 @@ class SolverState { this.finishedAt = saved.finishedAt; this.isRestored = true; this.optimizationError = null; + // Off the record rather than assumed from `configState`: the two are + // allowed to disagree, once `rescoreResult` has left them that way. A + // record written before this field existed carries none — but the anomaly + // was already folded into `signature` by then, and the signature matched + // to get here, so the current selection is what it was found under. + this.resultAnomalyId = saved.anomalyId ?? configState.activeAnomaly.id; } /** @@ -527,6 +553,7 @@ class SolverState { this.finishedAt = null; this.elapsedMs = 0; this.isRestored = false; + this.resultAnomalyId = null; } /** @@ -590,6 +617,12 @@ class SolverState { this.#scoreLayout(placements ?? this.variants[i].placements, bound), ); this.optimizationResult = this.variants[this.variantIndex] ?? null; + // `resultAnomalyId` deliberately stays where it is. This puts every + // variant's *figures* under the current anomaly but searches nothing, so + // the shapes are still the ones the old rules produced. Moving it to the + // current selection erased the "solved under a different timeline" warning + // it exists to raise, and let Run's "keep the better one?" dialog defend a + // layout that had never been searched under the new rules. this.#persistCurrent(); } @@ -623,6 +656,7 @@ class SolverState { finishedAt: number, templateId: string, signature: string, + anomalyId: AnomalyId, ) { const applied = variants[selectedVariant]; if (!applied) return; @@ -637,6 +671,7 @@ class SolverState { toStoredPlacements($state.snapshot(v).placements), ), selectedVariant, + anomalyId, durationMs, finishedAt, }); @@ -654,6 +689,10 @@ class SolverState { if (this.variants.length === 0) return; if (this.lastRunDurationMs === null || this.finishedAt === null) return; + // `resultAnomalyId`, not the current selection: `rescoreResult` calls this + // after re-rating the figures under a newly chosen anomaly without + // re-searching, and the record has to keep saying which one the *shape* + // was found under. this.#persistResult( this.variants, this.appliedVariant, @@ -661,6 +700,7 @@ class SolverState { this.finishedAt, layoutState.activeTemplateId, this.solveSignature(), + this.resultAnomalyId ?? configState.activeAnomaly.id, ); } @@ -705,11 +745,17 @@ class SolverState { : null; const previousDurationMs = this.lastRunDurationMs; const previousFinishedAt = this.finishedAt; + const previousAnomalyId = this.resultAnomalyId; - // Captured before anything can await: this is the island being solved, and - // the board it is being solved against. + // Captured before anything can await: this is the island being solved, the + // board it is being solved against, and the rules it is solved under. this.#runTemplateId = layoutState.activeTemplateId; this.#runSignature = this.solveSignature(); + this.#runAnomalyId = configState.activeAnomaly.id; + // The streamed and then finished layouts are both searched under the + // anomaly launched with, which the player may move off before either + // lands — see `resultAnomalyId`. + this.resultAnomalyId = this.#runAnomalyId; this.isOptimizing = true; this.isStopping = false; @@ -788,7 +834,7 @@ class SolverState { // only through `anomalyId`, because every rule that reads it is // resolved on the far side of the worker boundary — per tile, per // layout, or in how the board is split. - anomalyId: configState.activeAnomaly.id, + anomalyId: this.#runAnomalyId, prestige: configState.prestige, }, ); @@ -835,6 +881,11 @@ class SolverState { const finishedAt = defended ? (previousFinishedAt ?? Date.now()) : Date.now(); + // A defended shortlist is the *held* layouts, rated under whatever + // anomaly was selected when they were made — not this run's. + const winningAnomalyId = defended + ? (previousAnomalyId ?? this.#runAnomalyId) + : this.#runAnomalyId; // Filed under the island it was started on, whether or not that is // still the one being looked at. @@ -845,6 +896,7 @@ class SolverState { finishedAt, this.#runTemplateId, this.#runSignature, + winningAnomalyId, ); if (onScreen()) { this.variants = variants; @@ -854,6 +906,7 @@ class SolverState { this.elapsedMs = winningDurationMs; this.lastRunDurationMs = winningDurationMs; this.finishedAt = finishedAt; + this.resultAnomalyId = winningAnomalyId; } donePower = variants[selected]?.totalPower ?? 0; } else if (onScreen()) { @@ -873,6 +926,7 @@ class SolverState { this.lastRunDurationMs = previousDurationMs; this.finishedAt = previousFinishedAt; this.elapsedMs = previousDurationMs ?? 0; + this.resultAnomalyId = previousAnomalyId; } // Every `solve_run` gets one of these, so a status other than `ok` is diff --git a/src/lib/state/ui.svelte.ts b/src/lib/state/ui.svelte.ts index 1957064..9d763d6 100755 --- a/src/lib/state/ui.svelte.ts +++ b/src/lib/state/ui.svelte.ts @@ -599,7 +599,21 @@ class UIState { ); return; } - if (solverState.optimizationResult) { + /* + * Nothing to ask about across an anomaly change: the layout on screen was + * searched under other rules, so its power is not a bar a fresh run has + * to clear — defending it would compare two numbers that do not mean the + * same thing. Run outright, as if there were no result at all. + * + * Compared against `activeAnomaly.id` rather than the raw `anomalyId`, + * which is whatever `localStorage` held: `resultAnomalyId` is always a + * resolved id, so an unknown string would otherwise never match one and + * the dialog would be skipped for good. + */ + if ( + solverState.optimizationResult && + solverState.resultAnomalyId === configState.activeAnomaly.id + ) { this.activeModal = "solve"; return; } diff --git a/src/lib/storage/storage.ts b/src/lib/storage/storage.ts index 37224a8..a93c6d7 100755 --- a/src/lib/storage/storage.ts +++ b/src/lib/storage/storage.ts @@ -3,6 +3,7 @@ import type { ImageScale, PlacementView } from "../types/ui"; import { DEFAULT_SOLVE_MODE, type SolveModeId } from "../worker/solveModes"; import { DEFAULT_ANOMALY_ID, + type AnomalyId, type OptimizationResult, } from "@reactor2/solver"; @@ -126,6 +127,19 @@ export interface SavedSolveData { variants?: StoredPlacement[][]; /** Which entry of `variants` the user applied. */ selectedVariant?: number; + /** + * The anomaly `result`/`variants` were actually *searched* under. + * + * Not the same fact as the anomaly inside `signature`, which is the one the + * record was last filed under: re-scoring a standing solve after the player + * picks another anomaly re-rates its figures and re-files it under the new + * signature without searching again, so the shapes stay the old rules'. + * This is what survives the reload still saying so. + * + * Absent on a record written before the field existed, where a matching + * signature already settles it — see `solverState.restore`. + */ + anomalyId?: AnomalyId; /** Wall-clock length of the run, in ms. */ durationMs: number; /** When it finished, epoch ms. Shown as "solved N ago". */ From a079b957dd5c39adf8c1deeef087f8fdeb553a06 Mon Sep 17 00:00:00 2001 From: OGMods <3816927+OGMods@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:06:39 +0300 Subject: [PATCH 13/21] Make the anomaly a theme, and name tokens for the role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An anomaly was spelled out eleven times. Ten components each carried `class:anomalous={configState.hasAnomaly}` and a `.anomalous` rule that re-pointed *a different subset* of the same tokens, which is how the HUD's ground went purple while the selected tool standing on it stayed cyan, and how `BuildingPalette` came to remember `--text-dim` where `ObstaclePalette` beside it did not. Every new panel was another place to remember, and forgetting was silent: the panel simply looked like chrome someone had missed. `App.svelte` now puts `data-theme="anomaly"` on `.app-shell` and one block in `app.css` rebinds the tokens under it. Every surface inside the shell follows — the header, the HUD's pills and ribbons, the readout, Setup, the overflow menu, the dialogs, the toast. No component reads `hasAnomaly` and none of them carries a rule about anomalies at all; six lost the `configState` import outright. It hangs on the shell because that is the one place allowed to see `configState` beside everything else, and because a theme applied in eleven places is eleven places to get it wrong. Three tokens make the rebinding possible, each replacing something that was hard-coded in the base theme too. `--surface-panel-rgb` is the ground as a bare triplet, so the eight rules that wrote `rgba(10, 14, 23, 0.9x)` compose from it and each surface keeps its own alpha over the board — the sheet 0.97, the readout 0.94, the HUD's pills 0.92, the docked panel 0.88. Alpha is the component's business and hue is the theme's, so selecting an anomaly changes the colour and nothing about how much board shows through. `--surface-inset` is Setup's foot, which tints navy in the base theme and darkens under this one because navy on purple fights. And `--action` is the one *filled* primary, RUN: a fill carrying ink, where brighter is less legible, against `--accent`'s stroke on a dark ground, where brighter is more. The base theme answers both with the same cyan, which is why they read as one token until a theme needed them apart — the selection lavender carries neither ink at AA. `--neon` is now `--accent`, with its family and `--border-neon`. The name described one theme's answer and stopped being true the first time a theme made it purple, which is worse than a vague name because it reads as true. The law already stated the role. `ModalShell`'s own `--accent` had to move to `--modal-accent` in the same pass: left alone, its `var(--neon)` default became `--accent: var(--accent)` on the same element, a cyclic dependency that CSS drops as invalid at computed-value time — the dialog's border, glow and title would have gone silently colourless. `gridPainter`'s `NEON` is `ACCENT` and now says what is true of it: Pixi cannot read a custom property, so it is pinned to the base theme and does not follow the shell. Two things stay out of a theme deliberately. The board's own language — `--status-ok`, `--status-idle`, `--danger`, `--warn` — is never rebound, since the pad under a building, the pulse that breathes it and the readout's red Cooling row mean the same thing under every timeline, and an anomaly is precisely when a player most needs them to. And the game's chooser palette (`--anomaly-card`, `--anomaly-selected`, the benefit/drawback pair) is not a theme either: those are `AnomalySelector`'s own colours, sampled from a screen in the game, and only the ground under them is themed. That is why the prefix now means one thing instead of two. Driven in a real browser, both themes, desktop and phone: the base theme is unchanged, and under an anomaly the readout's Idle amber and Overheating red are pixel-identical to the base shot while the ground beneath them is purple. Per the earlier decision to theme everything, marks that used to sit cyan on a purple panel now follow it — the HUD's active tool, the selected building in the palette, the readout's accents — and the Setup button and collapse handle take the selection lavender rather than the game's badge purple, which was an anomaly-only emphasis with three different base values and so not a themeable role at all. --- CLAUDE.md | 144 ++++++---- src/App.svelte | 12 +- src/app.css | 272 ++++++++++++------ src/lib/components/header/GridControls.svelte | 31 +- src/lib/components/header/Header.svelte | 20 +- src/lib/components/header/OverflowMenu.svelte | 34 +-- .../components/header/PreviewBanner.svelte | 6 +- src/lib/components/hud/BoardActions.svelte | 56 +--- src/lib/components/hud/BuildingPalette.svelte | 20 +- .../components/hud/BuildingPaletteItem.svelte | 8 +- src/lib/components/hud/HudToolbar.svelte | 53 ++-- src/lib/components/hud/ObstaclePalette.svelte | 21 +- .../components/hud/PlacementViewToggle.svelte | 32 +-- .../inspector/BoardStatsCard.svelte | 62 ++-- src/lib/components/modals/ImportModal.svelte | 8 +- src/lib/components/modals/ModalShell.svelte | 42 +-- .../components/modals/SettingsModal.svelte | 18 +- src/lib/components/modals/ShareModal.svelte | 10 +- src/lib/components/modals/SolveModal.svelte | 8 +- .../modals/SolveModeInfoModal.svelte | 10 +- .../components/sidebar/AnomalySelector.svelte | 4 +- .../sidebar/BuildingUnlockCard.svelte | 2 +- .../sidebar/BuildingUnlockList.svelte | 10 +- .../components/sidebar/ConfigSidebar.svelte | 117 ++------ .../sidebar/PrestigeUpgrades.svelte | 16 +- .../sidebar/SolveModeSelector.svelte | 6 +- .../sidebar/TemplateSelector.svelte | 26 +- src/lib/pixi/gridPainter.ts | 32 ++- src/lib/state/ui.svelte.ts | 2 +- 29 files changed, 495 insertions(+), 587 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5a8c85..e61b200 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1858,7 +1858,7 @@ in their chrome and their gesture, never in this. Four things about it: because the island list is scrolled there. - **It is an underline, where the category tabs inside the roster are filled pills.** Two rows of identical tabs stacked on each other read as one confusing - row of five. Both take `--neon` for the active one, because the colour law has a + row of five. Both take `--accent` for the active one, because the colour law has a single meaning for "this is selected"; the hierarchy is carried by shape. Two things follow. The roster's category tab bar is **sticky** at the top of the @@ -2000,6 +2000,22 @@ panel — under WCAG AA. Measure against the _lightest_ backdrop a panel makes (roughly `#112226`), not `--surface-void`, because light text loses contrast on the lighter one. +### Tokens are named for the role, never the colour + +`--accent` was called `--neon` until a theme made it purple, at which point the +name was simply wrong in half the app — and a wrong name is worse than a vague +one, because it reads as true. The same trap is waiting for anything spelled +`--purple`, `--amber` or `--cyan`: a themed token names **what the colour is +for**, and a theme decides what it holds. + +Two corollaries. `--action` is a separate token from `--accent` even though the +base theme answers both with the same cyan — they are different roles (a fill +carrying ink, against a stroke on a dark ground) and a theme separates them, so +collapsing the two would only have to be undone. And `ACCENT` in +`pixi/gridPainter.ts` keeps the value as a numeric literal because Pixi cannot +read a custom property: it is pinned to the **base** theme and says so, since a +name promising it follows the shell would be the same lie again. + ### The colour law The law lives at the top of `app.css`, one line each. Without it the palette @@ -2008,12 +2024,19 @@ drifts to twelve-odd hues — a Share button wearing the colour the board uses f | | means | | --------------- | ----------------------------------------------------------- | -| `--neon` | this control is selected / active. Nothing else. | +| `--accent` | this control is selected / active. Nothing else. | +| `--action` | the one *filled* primary control. RUN, and nothing else. | | `--status-ok` | the board only: this building is working. | | `--status-idle` | the board only: this building is doing nothing. | | `--danger` | the board: overheating. In the UI: this destroys something. | | `--warn` | a limit is reached, or this board is not yours to edit. | -| `--anomaly-*` | Setup's Anomaly tab only, and nowhere else. | +| `--anomaly-*` | `AnomalySelector`'s cards only, and nowhere else. | + +The first two are themed, the four below them never are — see the theme block +in `app.css`. `--anomaly-*` is neither: it is the game's own chooser palette, +not a theme, which is why it kept its name while the purple that used to share +that prefix became the anomaly theme's binding of `--accent`, `--text` and the +rest. **The two board readings are reserved, and that is the whole point.** The pad under every building, the pulse that breathes it and the readout in the corner all speak @@ -2044,68 +2067,79 @@ have looked right: the board speaks as a saturated accent on a dark ground, thes are muted fills carrying near-white text. A player meets them as panels, not as status lights, which is what keeps the reservation honest. -**`--anomaly-selected` is the sharper half of that exception**, because `--neon` +**`--anomaly-selected` is the sharper half of that exception**, because `--accent` means selected everywhere else and on this list it does not: the chosen card is ringed in the game's own green. Two selection colours is a real cost, and it is taken for the same reason and stretches no further — showing a player their own choice in a colour they will not recognise from the screen they made it on is the larger one. Nothing outside `AnomalySelector` may take it. -**Three surfaces go purple while an anomaly is selected** — Setup on *every* -tab, the readout, and Run in the HUD — all reading `configState.hasAnomaly`, so -it is one signal rather than three effects. Keyed on the selection and not on -the Anomaly tab, because that is what it says: this timeline is not running the -ordinary rules, and the island list, the roster and the board's figures are all -read under them. Nothing selected puts every one of them back to navy, which is -the common case. Each surface keeps **its own alpha** — sheet 0.97, docked panel -0.88, readout 0.94 — which is why `--anomaly-panel-rgb` is a bare triplet rather -than a colour: one decision, not three tokens. - -The type and chrome follow by **re-pointing the inherited tokens** on each container — `ConfigSidebar` -and `BoardStatsCard` re-point `--text`, `--text-muted`, `--text-dim` and -`--border` at the `--anomaly-*` values, plus `--border-neon`, `--neon-faint` and -`--surface-panel-solid` on Setup, so every hairline and the roster's sticky bar -go along too — and no component learns that anomalies exist. - -Setup re-points the **whole `--neon` family** on top of that, so every mark -inside it that means "selected" goes purple with the ground: the SETUP heading, -the active tab's label and underline, the chosen island's row, the roster's -category pills. The `--anomaly-neon-*` tokens mirror the `--neon-*` ones name for -name so the mapping reads straight down. They are a **lighter** purple than -`--anomaly-accent`, and that is forced: cyan earns its prominence by contrast, -and the badge purple reads 3.3:1 against this panel — right as a stroke on the -dark collapse handle, unreadable as a heading. `#c9a5f0` puts back the 6.5:1 the -cyan had. - -That leaves two purples meaning "selected" inside the panel, which is deliberate: +**An anomaly is a theme, not a hundred conditionals.** `App.svelte` — the one +place allowed to see `configState` beside everything else — puts +`data-theme="anomaly"` on `.app-shell` while one is selected, and a single +block in `app.css` rebinds the tokens under it. Every surface inside the shell +follows: the header, the HUD's pills and ribbons, the readout, Setup, the +overflow menu, the dialogs, the toast. No component reads `hasAnomaly` and none +of them carries a rule about anomalies at all. + +It replaced eleven copies of the signal — ten components each with +`class:anomalous={configState.hasAnomaly}` and a `.anomalous` rule re-pointing +*a different subset* of the same tokens, which is exactly how the HUD's ground +went purple while the selected tool on it stayed cyan, and how +`BuildingPalette` came to remember `--text-dim` where `ObstaclePalette` beside +it did not. Every new panel was another place to remember, and forgetting was +silent. + +Keyed on the selection and not on Setup's Time Lab tab, because that is what it +says: this timeline is not running the ordinary rules, and the island list, the +roster and the board's figures are all read under them. Nothing selected leaves +the attribute off and every token falls back to the navy in `:root`, which is +the common case. + +Four things about what a theme may and may not move: + +- **Alpha is the component's, hue is the theme's.** Each surface picks its own + strength over the board — the sheet 0.97, the readout 0.94, the HUD's pills + 0.92, the docked panel 0.88 — by composing `rgba(var(--surface-panel-rgb), + …)`. That is why the ground is a bare triplet rather than a colour: one + decision, not one token per surface. Selecting an anomaly changes the hue and + nothing about how much board shows through. +- **The whole `--accent` family goes**, so every mark meaning "selected" follows + the ground under it: Setup's heading, its active tab, the chosen island's + row, the roster's category pills, the HUD's active tool, the selected + building in the palette, the readout's accents. The purple is **lighter** + than the game's badge purple, and that is forced — cyan earns its prominence + by contrast, and the badge purple reads 3.3:1 on this panel, unreadable as a + heading. `#c9a5f0` puts back the 6.5:1 the cyan had. +- **`--action` is a separate role from `--accent`, and this is what it is for.** + RUN is the one *filled* control in the app: a fill carrying ink, where + brighter is *less* legible, against `--accent`'s stroke-on-dark, where brighter + is more. The base theme answers both with the same cyan, which is why they + looked like one token until a theme needed them apart — the selection + lavender carries neither ink at AA, so `--action` is the badge purple pulled + two steps down its own ramp (5.4:1 resting, 4.8:1 hover; RUN is 12.8px bold, + so 4.5 is the bar). Stop is untouched either way: a stop is destructive of + the run in progress whatever rules it began under. +- **The board's own language is never rebound.** `--status-ok`, + `--status-idle`, `--danger` and `--warn` stay out of every theme. The pad + under a building, the pulse that breathes it and the readout's red Cooling + row mean the same thing under every timeline, and an anomaly is precisely + when a player most needs them to. + +**The game's chooser palette is not a theme either**, and that is the other +half of the split: `--anomaly-card`, `--anomaly-selected` and the +benefit/drawback pair are `AnomalySelector`'s own colours, sampled from a +screen in the game, and they do not move with the ground. They stay in `:root` +for that reason — only the ground under them is themed. Two of the three text +values are **lifted** off what the game uses, since its secondary lavender +measures 3.8:1 on this panel, under AA — the same trap `--text-dim` was lifted +out of once already. + +That leaves two purples meaning "selected" inside Setup, which is deliberate: the lavender is the **app** saying which tab or row you are on, while the green ring on an anomaly card is the **game's**, and the card is a second view of the game's own chooser. -One thing is deliberately **not** re-pointed: the **board's own reds and ambers** -in the readout. They mean the same thing under every anomaly, and an anomaly is -precisely when a player most needs them to. - -Two of the three text values are **lifted** off what the game uses — its -secondary lavender measures 3.8:1 on this panel, under AA, the same trap -`--text-dim` was lifted out of once already. The cards on it are the game's, so the ground -under them goes along rather than leaving them floating on a navy belonging to -the rest of the panel. Each shell keeps its **own** alpha — the sheet 0.97, the -docked panel 0.88 — so the hue changes and how much board shows through does -not; and the docked foot darkens rather than keeping its navy tint, which would -fight the purple. It is a view, not a mode: nothing else in the app reads it. - -**Run in the HUD takes the colour too**, and Stop is untouched: a stop is -destructive of the run in progress whatever rules it began under. The docked panel's collapse handle takes it on the -same condition, which is what keeps it from ever sitting purple against a navy -panel. - -Run and the handle take **different purples**, and that is a contrast rule rather than a -taste one: `--anomaly-accent` is the game's badge purple for strokes and glyphs -on a dark ground, where brighter is more legible, while `--anomaly-action` is a -fill behind white text, where brighter is less — the badge purple carries white -at 4.0:1, so the filled pair sits two steps down the same ramp. - That list also **stays compact until a card is chosen** — the unselected size is the default and `.active` is what loosens it (a larger icon, roomier panels, and the full rule). Three of the four describe a timeline nobody is in, and sizing it diff --git a/src/App.svelte b/src/App.svelte index b9ff612..e09700a 100755 --- a/src/App.svelte +++ b/src/App.svelte @@ -229,9 +229,19 @@ palette ribbon opens. `--preview-clearance` is the shared-link banner, which grows a line whenever an import is refused and is 0px on every board but a shared one. All three are published rather than assumed. + + `data-theme` is the other thing this element carries, and it is the whole of + how an anomaly is signalled. The token rebindings live in one block in + `app.css`, and every surface inside the shell — the header, the HUD's pills, + the readout, Setup, the dialogs, the toast — follows without a single + component learning that anomalies exist. It hangs here rather than on each + panel because this is the one place allowed to see `configState` beside + everything else, and because a theme applied in eleven places is eleven + places to forget. -->
the same lavender family. 7.0:1 on the panel. */ - --anomaly-text-muted: #c3b4dd; - /* The game's `#9281ad`, lifted to clear AA on the panel. 4.7:1. */ - --anomaly-text-dim: #a091bd; - --anomaly-border: rgba(230, 216, 252, 0.14); - - /* - * Two purples for controls, and the split is a contrast one rather than a - * taste one. `--anomaly-accent` is the game's own badge purple, used for - * *strokes and glyphs on a dark ground*, where brighter is more legible. - * `--anomaly-action` is a *fill behind white text*, where brighter is less: - * the badge purple carries white at only 4.0:1, so the filled pair is pulled - * two steps down the same ramp until both the resting and the hover state - * clear AA (5.4:1 and 4.8:1). RUN is 12.8px bold, which is not "large text", - * so 4.5 is the bar it has to meet. - */ - --anomaly-accent: #9b65d4; - --anomaly-accent-dim: rgba(155, 101, 212, 0.45); - --anomaly-action: #8450bd; - --anomaly-action-hover: #8d5ac6; - /* A limit reached, or a board that is not yours. Never a plain action. */ --warn: #f59e0b; --warn-dim: rgba(245, 158, 11, 0.4); @@ -282,7 +249,7 @@ --text-dim: #7d8da4; --border: rgba(255, 255, 255, 0.08); - --border-neon: rgba(0, 243, 255, 0.25); + --border-accent: rgba(0, 243, 255, 0.25); /* ── Safe areas ─────────────────────────────────────────────────── * `env()` resolves to 0px on anything without a notch, so these are @@ -364,6 +331,123 @@ -webkit-text-size-adjust: 100%; } +/* + * ── Themes ──────────────────────────────────────────────────────── + * + * A theme is a *rebinding of the tokens above*, applied once, on the shell. + * Nothing below this line is referenced by a component: they go on reading + * `--surface-panel-rgb`, `--text`, `--accent`, `--action` and the rest, and a + * theme changes what those answer. + * + * That is the whole reason this block exists. The anomaly purple used to be + * spelled out eleven times — ten components each carrying + * `class:anomalous={configState.hasAnomaly}` and a `.anomalous` rule that + * re-pointed *a different subset* of the same tokens, so the HUD's ground went + * purple while the selected tool on it stayed cyan, and `BuildingPalette` had + * to remember `--text-dim` where `ObstaclePalette` beside it did not. Every + * new panel was another place to remember, and forgetting was silent. + * + * Two things stay out of a theme deliberately: + * + * - **The board's own language.** `--status-ok`, `--status-idle`, `--danger` + * and `--warn` are never rebound. The pad under a building, the pulse that + * breathes it and the readout's red Cooling row mean the same thing under + * every timeline, and an anomaly is precisely when a player most needs them + * to. + * - **The game's chooser palette** — `--anomaly-card`, `--anomaly-selected` + * and the benefit/drawback pair. Those are `AnomalySelector`'s own colours, + * sampled from a screen in the game, and they do not move with the ground. + * They live in `:root` above for that reason; only the ground under them is + * a theme. + */ + +/* + * ── The anomaly theme ────────────────────────────────────────────── + * + * `App.svelte` sets `data-theme="anomaly"` on `.app-shell` whenever + * `configState.hasAnomaly` is true — on every tab and every board, because + * that is what it says: this timeline is not running the ordinary rules, and + * the island list, the roster and the board's figures are all read under them. + * Nothing selected leaves the attribute off and every token below falls back + * to the navy above, which is the common case. + * + * The values are sampled from the game's own "Choose an anomaly" screen, so + * the cards on that tab sit on the ground they came from rather than floating + * on a navy belonging to the rest of the app. + */ +[data-theme="anomaly"] { + /* + * The ground, as a triplet, because the surfaces wearing it each keep their + * own alpha — the sheet 0.97, the docked panel 0.88, the readout 0.94, the + * HUD's pills 0.92. Selecting an anomaly changes the hue and nothing about + * how much board shows through. + */ + --surface-panel-rgb: 56, 38, 80; + /* The header's own 0.94, and the one surface still happy with the default. */ + --surface-panel: rgba(var(--surface-panel-rgb), 0.94); + /* For anything that must not be seen through: the roster's sticky category + bar, the overflow menu, a dialog, the toast. */ + --surface-panel-solid: #382650; + /* The HUD's two ribbons, which sit a shade deeper than a panel. */ + --surface-sunken: rgba(var(--surface-panel-rgb), 0.92); + /* Setup's foot darkens rather than tinting: a navy tint fights this ground, + and a neutral separates the run control from either one. */ + --surface-inset: rgba(0, 0, 0, 0.22); + + /* + * Type for that ground, sampled from the same screens. The app's own text + * tokens are cyan-leaning (`--text` is `#e0f0ff`, `--text-dim` a blue-grey) + * and read as the wrong family on purple — the panel would look like our + * chrome with someone else's background behind it. + * + * `--text` is the game's `#e6d8fc` untouched, at 10:1 on the panel. The + * other two are **lifted** off their sampled values, for the reason + * `--text-dim` was lifted once already: the game's secondary lavender + * (`#9281ad`) measures 3.82:1 here, under AA. Measure against the panel + * rather than the cards — the panel is the lighter of the two grounds, and + * light type loses contrast on the lighter one. + */ + --text: #e6d8fc; + /* `#94a3b8` -> the same lavender family. 7.0:1 on the panel. */ + --text-muted: #c3b4dd; + /* The game's `#9281ad`, lifted to clear AA on the panel. 4.7:1. */ + --text-dim: #a091bd; + --border: rgba(230, 216, 252, 0.14); + + /* + * The whole selection family in purple, so every mark that means "selected" + * goes with the ground under it: Setup's heading, its active tab, the chosen + * island's row, the roster's category pills, the HUD's active tool, the + * selected building in the palette, the readout's accents. + * + * It is a **lighter** purple than the fill below, and that is forced rather + * than chosen. Cyan earns its prominence by contrast, and the game's badge + * purple (`#9b65d4`) reads 3.3:1 against this panel — fine as a stroke, + * unreadable as a heading. `#c9a5f0` restores the 6.5:1 the cyan had, in the + * same family. + */ + --accent: #c9a5f0; + --accent-strong: #d8bdf7; + --accent-dim: rgba(201, 165, 240, 0.4); + --accent-line: rgba(201, 165, 240, 0.5); + --accent-glow: rgba(201, 165, 240, 0.2); + --accent-bg: rgba(201, 165, 240, 0.16); + --accent-faint: rgba(201, 165, 240, 0.18); + --border-accent: rgba(201, 165, 240, 0.3); + + /* + * And the filled action, which is where the split from `--accent` earns its + * keep. RUN is a fill behind white text, where brighter is *less* legible: + * the selection lavender above carries white at nothing like AA, so this is + * the game's badge purple pulled two steps down the same ramp until both the + * resting and the hover state clear it (5.4:1 and 4.8:1). RUN is 12.8px + * bold, which is not "large text", so 4.5 is the bar it has to meet. + */ + --action: #8450bd; + --action-hover: #8d5ac6; + --action-ink: #ffffff; +} + *, *::before, *::after { @@ -406,7 +490,7 @@ button { * keeps it off pointer taps, where a persistent ring reads as stuck. */ :where(button, [href], input, select, textarea, [tabindex]):focus-visible { - outline: 2px solid var(--neon); + outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-xs); } @@ -433,7 +517,7 @@ button { /* Thin dark scrollbars for the panels that scroll internally. */ .thin-scroll { scrollbar-width: thin; - scrollbar-color: var(--neon-dim) transparent; + scrollbar-color: var(--accent-dim) transparent; } .thin-scroll::-webkit-scrollbar { width: 6px; @@ -444,7 +528,7 @@ button { border-radius: var(--radius-xs); } .thin-scroll::-webkit-scrollbar-thumb { - background: var(--neon-dim); + background: var(--accent-dim); border-radius: var(--radius-xs); } diff --git a/src/lib/components/header/GridControls.svelte b/src/lib/components/header/GridControls.svelte index e76baae..5da5a9e 100755 --- a/src/lib/components/header/GridControls.svelte +++ b/src/lib/components/header/GridControls.svelte @@ -1,6 +1,6 @@ -
+