diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c01dfd..5feb7f1 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,17 @@ name: CI -# Pushes to any branch listed here build, and the `publish` job below puts the -# result on the Pages site: `main` at the root, every other branch in a -# directory named after itself (`anomalies` serves at `/anomalies/`). That makes -# this list the single place a branch opts into a preview -- there is no second -# allow-list in the job to keep in step with it, and no branch but `main` can -# reach the root. on: push: - branches: [main, anomalies] + branches: [main] pull_request: workflow_dispatch: -# A superseded run tells you nothing, so cancel it -- except on a push, which -# may publish, and a cancelled publish could abandon a half-finished site. Only -# pull-request runs are safe to cancel, since they publish nothing. +# A superseded run tells you nothing, so cancel it -- except on main, where a +# cancelled run could abandon a half-finished Pages deployment. `github.ref` is +# in the key, and the deploy job carries its own non-cancelling group below. concurrency: group: ci-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: contents: read @@ -52,14 +46,10 @@ jobs: - name: Build run: npm run build - # Handed to `publish` below rather than to actions/deploy-pages: the site - # is a branch now, so the build travels as an ordinary artifact. - - name: Upload the build - uses: actions/upload-artifact@v7 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 with: - name: web-dist path: dist - retention-days: 7 solver: name: Solver package @@ -96,142 +86,27 @@ jobs: - name: CLI runs run: npm run solve -- --map 0 --attempts 2 --workers 2 --time 2 - publish: - name: Publish to Pages + deploy: + name: Deploy to GitHub Pages needs: [web, solver] - # Pushes only. A pull request must not move the published site, and a manual - # run is for re-checking a build, not for shipping one. - if: github.event_name == 'push' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest - # Pushing to gh-pages is an ordinary commit, so this needs write access to - # the repository rather than the `pages:`/`id-token:` pair deploy-pages took. permissions: - contents: write + pages: write + id-token: write - # Every branch publishes into the *same* branch, so these runs must not - # overlap however many branches are pushed at once -- and never cancel one - # in flight, since a half-applied site is undefined. Queue instead. + # Never cancel a deployment in flight: a half-applied one leaves the site + # in an undefined state. Queue instead. concurrency: - group: pages-publish + group: pages cancel-in-progress: false - steps: - - name: Download the build - uses: actions/download-artifact@v8 - with: - name: web-dist - path: dist + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} - - name: Publish into the site branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH: ${{ github.ref_name }} - run: | - set -euo pipefail - - # main owns the root; every other branch owns one directory named - # after itself. `base: "./"` in vite.config.ts is what lets the - # identical build serve from either depth -- a configured base would - # be wrong at one of them. - if [ "$BRANCH" = "main" ]; then - dest="." - else - dest="$BRANCH" - fi - - # A preview sits beside the root's own files rather than under a - # `preview/` prefix, so two names could collide with it. Both are - # refused rather than merged, because either one silently overwrites - # part of the published site: a branch whose name matches something - # the build itself puts at the top level, and a branch whose name has - # a slash in it, which would bury its marker below the depth the root - # publish looks at. - if [ "$dest" != "." ]; then - case "$BRANCH" in - */*) - echo "A preview branch name cannot contain a slash: $BRANCH" >&2 - exit 1 - ;; - esac - if [ -e "dist/$BRANCH" ]; then - echo "Branch '$BRANCH' collides with dist/$BRANCH in the build" >&2 - exit 1 - fi - fi - echo "Publishing $BRANCH to ${dest#./}" - - git config --global user.name "github-actions[bot]" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - - remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - mkdir site && cd site - git init --quiet --initial-branch=gh-pages - git remote add origin "$remote" - - # The branch does not exist on the first run, and an orphan checkout is - # how it starts: no history from main, since the built site shares - # nothing with the source tree. - if git fetch --quiet --depth 1 origin gh-pages 2>/dev/null; then - git checkout --quiet -B gh-pages FETCH_HEAD - else - echo "gh-pages does not exist yet -- starting it" - fi - - # Replace this build's own directory and nothing else, so the root and - # every other branch's preview survive a publish that is not theirs. - # A preview says what it is with a `.preview` marker, and the root - # publish holds back anything carrying one -- which is what keeps the - # set of previews recorded by the site itself rather than by a list of - # branch names here that would drift from the trigger above. - if [ "$dest" = "." ]; then - for entry in * .[!.]*; do - [ -e "$entry" ] || continue - [ "$entry" = ".git" ] && continue - if [ -d "$entry" ] && [ -f "$entry/.preview" ]; then - echo "Keeping the $entry preview" - continue - fi - rm -rf "$entry" - done - else - rm -rf "$dest" - fi - mkdir -p "$dest" - cp -R ../dist/. "$dest"/ - [ "$dest" = "." ] || touch "$dest/.preview" - - # Pages runs Jekyll over a branch source unless told not to, and Jekyll - # silently drops files whose names begin with an underscore. - touch .nojekyll - - git add -A - if git diff --quiet --cached; then - echo "Site is already identical -- nothing to publish" - exit 0 - fi - git commit --quiet -m "Publish $BRANCH ($GITHUB_SHA) to ${dest#./}" - - # The cleanup workflow pushes to this branch too, so lose a race - # rather than fail one. - for attempt in 1 2 3; do - if git push --quiet origin gh-pages; then - exit 0 - fi - echo "Push rejected (attempt $attempt) -- rebasing onto the branch" - git fetch --quiet --depth 1 origin gh-pages - git rebase --quiet FETCH_HEAD || git rebase --abort - done - echo "Could not publish after three attempts" >&2 - exit 1 - - - name: Where it landed - env: - BRANCH: ${{ github.ref_name }} - run: | - base="https://${GITHUB_REPOSITORY_OWNER}.github.io/${GITHUB_REPOSITORY#*/}" - if [ "$BRANCH" = "main" ]; then - echo "### Published to $base/" >> "$GITHUB_STEP_SUMMARY" - else - echo "### Published to $base/$BRANCH/" >> "$GITHUB_STEP_SUMMARY" - fi + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pages-preview-cleanup.yml b/.github/workflows/pages-preview-cleanup.yml deleted file mode 100644 index 87db496..0000000 --- a/.github/workflows/pages-preview-cleanup.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Remove a branch's Pages preview - -# A preview outlives the branch it was built from, and nothing in the publish -# path can notice: that job only ever touches the directory it is publishing. -# So deletion is its own event. This workflow always runs from the default -# branch, whatever branch was deleted. -on: - delete: - workflow_dispatch: - inputs: - branch: - description: "Branch whose preview should be removed" - required: true - type: string - -concurrency: - group: pages-publish - cancel-in-progress: false - -permissions: - contents: write - -jobs: - remove: - name: Remove the preview - # A deleted tag is not a preview. Guard on the ref type rather than on the - # name, since a tag and a branch may share one. - if: github.event_name == 'workflow_dispatch' || github.event.ref_type == 'branch' - runs-on: ubuntu-latest - - steps: - - name: Remove it from the site branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH: ${{ inputs.branch || github.event.ref }} - run: | - set -euo pipefail - - # main's build is the root of the site, not a preview, so there is - # nothing here to remove for it -- and the guard matters, because a - # preview now lives beside the root's own files rather than under a - # `preview/` prefix, so the path this would clear for "main" is the - # published site itself. - if [ "$BRANCH" = "main" ]; then - echo "main has no preview directory -- nothing to do" - exit 0 - fi - - git config --global user.name "github-actions[bot]" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - - remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - mkdir site && cd site - git init --quiet --initial-branch=gh-pages - git remote add origin "$remote" - - # No site branch means no preview to remove. Not a failure: a branch - # can be deleted before it ever published. - if ! git fetch --quiet --depth 1 origin gh-pages 2>/dev/null; then - echo "gh-pages does not exist -- nothing to do" - exit 0 - fi - git checkout --quiet -B gh-pages FETCH_HEAD - - if [ ! -d "$BRANCH" ]; then - echo "No preview for $BRANCH -- nothing to do" - exit 0 - fi - - # The `.preview` marker is what says this directory is a published - # preview rather than part of the root build, and it is the same test - # the root publish holds back on. Without it a branch sharing a name - # with one of the build's own directories would delete that directory - # out of the live site. - if [ ! -f "$BRANCH/.preview" ]; then - echo "$BRANCH exists but is not a preview -- refusing to remove it" >&2 - exit 1 - fi - - git rm -r --quiet "$BRANCH" - git commit --quiet -m "Remove the Pages preview for $BRANCH" - - # Races the publish job, which pushes to this same branch. - for attempt in 1 2 3; do - if git push --quiet origin gh-pages; then - echo "Removed the $BRANCH preview" - exit 0 - fi - echo "Push rejected (attempt $attempt) -- rebasing onto the branch" - git fetch --quiet --depth 1 origin gh-pages - git rebase --quiet FETCH_HEAD || git rebase --abort - done - echo "Could not remove the preview after three attempts" >&2 - exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index c2feb78..665a4b8 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,8 @@ assume them. - **Hidden, not disabled.** A control that cannot act in the current mode is not rendered — the grid steppers on a shipped island, the terrain brushes over the - solver's board, `PlacementViewToggle` with no solve, the variants row during a + solver's board, the whole of Setup while a run is in flight, + `PlacementViewToggle` with no solve or during one, the variants row during a run. Screen space on a phone is scarce and a row of inert buttons explains nothing. The one exception is `BoardActions`' Undo/Redo, which are _disabled_: they must stay findable before there is anything to undo, and a control that @@ -100,22 +101,422 @@ 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 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 eleven +researches: Absolute Zero (cooler cooling), Infinite Grid (generator and wind +turbine stats) and Stellar Forge (reactor heat). The other eight move research +income, research time, chronons, obstacle-removal, energy sale price 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`. 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 ×2 makes ×2 the waste and needs ×2 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 + 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 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 + +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. **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 fourth tier under Infinite Grid level 4 (x1.7) then a crowded + x0.8 is 1.2036e22, against 1.2036000000000001e22 for x1.36, and the order + shows too — under x1.25 then a x1.67 shore it is 1.8474375e22, against + 1.8474374999999997e22 the other way round. `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 + a solve found under another anomaly restores as stale. +- **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. + + **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 + 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. + + `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 + 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. + + **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 + 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. +- **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 + 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 + at full unlocks.** x4 scales a generator's *intake*, and a generator fed by + the reactors it already had simply fills to 25% 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 over three seeds: + 1.420e23 at best under both, where the one baseline layout whose generators + touch re-rates to 1.182e23 — so the search recovers the whole penalty and + finds no gain. Map 1 comes back identical to three significant figures either + way. + + **On a generator-bound roster it is not neutral, and the count is what the + search gets wrong.** `targetCompositions` is the one stage that decides *what* + to build, and how many generators are worth their tile depends on what a + generator is worth — two numbers under this rule, neither of them in the pool + the stage draws on (`rateRole` is the identity here, since which one a tile + gets is the layout's business). With everything unlocked but generator7 at + tier 1, Gale Hills plateaued at 43.2-45.2AC from 5s to **150s**. Sizing at the + bonus alone does not fix it — it asks for 20 generators where the island can + keep 15 apart. `generatorCapacityTable` builds `island.ts`'s own + `generatorCapacities` over `isolationRoom` — the same table the bound runs on, + imported rather than restated — and the target asks for 16: the mean of ten + runs goes 44.6AC to **46.2AC**, against a best of 49.8AC (fifteen generators, + every one isolated) that either reaches only occasionally. It pays most on + the largest boards (+12-14% on maps 7 and 8) and costs 1-4% on maps 2, 5 and + 6, over three seeds — a net gain, not a uniform one. + `islandRatingCeiling` takes `sizedByIsolation` so a target sized through the + table is not scaled by the rating a second time. Null under every other rule, + so the fixtures reproduce byte for byte. + + **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 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 + 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 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 + *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 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 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 the generator-bound + 3x3 in `island.test.ts` reads 267% against a bound that ignores Singularity. + A rule that scales a whole role goes **into the roster** the bound is run on + (`roleRatedRoster`: coolers ×0.88 under a pool, generators ×4 under + Singularity), where the bound is exact in it; scaling the LP's result by the + largest factor instead rates reactors and coolers up too and would leave the + Singularity bound 3.8x the tight one, so a near-optimal layout reads 25%. 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. + + **The one adjacency the bound does not relax away is `neighbourHeatCap`.** + Heat crosses a tile boundary and nothing else, so a generator's intake is + whatever the reactors beside it make, and the all-or-nothing cooling rule + wants coolers beside it too — out of the same eight tiles. So + `estimateIslandMaxPower` runs on `min(gVal, 8 / (1 / rVal + wasteRatio / + cVal))`, and the cooler term goes under a pool, where cooling reaches the + whole island. The cap is homogeneous like the LP, so a rule that scales + everything at once can never make it bite; a rule that scales **one role** + is what it takes, and Singularity's ×4 puts the top generator at 290% of + it — without the cap the bound sits 6-9% above anything the board allows, + reading 83-87% efficiency for layouts that are not 83-87% of anything. With + it the maps read 89.4-95.0%, and the base, Cryo and Tidal figures do not move + a digit. + + **The second half of that is `isolationRoom`: how many generators an island + can keep apart.** Isolated generators are pairwise non-adjacent, so they are + an independent set in the 8-neighbour graph — and on a roster where the + generators are the short side, the split wants a third of the island to be + one. Maximum independent set is NP-hard, so the ceiling is read off a 2x2 + block partition: four mutually adjacent tiles, so one isolated generator + between them and no other generator in that block at all. `crowdedRatedRoster` + is the pair to `roleRatedRoster` that lets `generatorCapacities` bend there + rather than running the bonus out to the whole island. Only the generator + role — it is the one figure the LP counts per tile — and every other role + keeps the better rating alone, which stays sound and loose. + + **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 + 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 — **multiplicatively**, so a generator under Singularity +Isolation with Infinite Grid maxed is rated x8. The extractor's record covers +all eleven 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`. Absolute Zero and Infinite Grid share one curve, 0.1 to 1.0; +Stellar Forge a shallower one, 0.1 to 0.5. 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 x1.1 to x2 (Absolute Zero, Infinite Grid) and +x1.1 to x1.5 (Stellar Forge). 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 ### Folder map @@ -167,8 +568,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 +578,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 +604,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 +×2-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,12 +630,43 @@ 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 `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 @@ -348,6 +810,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 @@ -375,7 +846,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 @@ -387,6 +858,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 @@ -650,12 +1137,17 @@ The measurement id is a constant, since it ships in the bundle anyway. view for someone who said no) or closed (the tag off for everyone), and neither is visible in the app. -**Four events beyond `page_view`**, each fired from the one place that knows +**Six events beyond `page_view`**, each fired from the one place that knows the answer: `solve_run` and `solve_done` in `runOptimizer` (paired, so a status other than `ok` is countable rather than inferred from a run that never -reported), `share_copy` in `copyShare`, and `board_failed` in `PixiCanvas`'s +reported), `share_copy` in `copyShare`, `board_failed` in `PixiCanvas`'s init catch — one event for both halves, since an atlas that never arrives and a -WebGL context that never starts are the same empty board. `trackEvent` buffers +WebGL context that never starts are the same empty board — and the Time Lab's +two, `anomaly_select` in `configState.setAnomaly` and `research_set` in +`toggleResearch` / `setResearchLevel`. Both solve events also carry the rules +the run was launched under (`configState.rulesParams()`: `anomaly`, and +`research` as `id:level` pairs numbered from 1), captured at launch, so power +and bound can be split by timeline. `trackEvent` buffers until the tag lands, because it is fetched on idle and the app is usable well before that; nothing buffered before an opt-out is ever sent. A param is not reportable until it is registered as a custom dimension or metric in GA. @@ -778,10 +1270,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. @@ -1219,8 +1728,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. @@ -1231,6 +1741,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. @@ -1259,6 +1780,78 @@ lifted above the sheet, so dismissing it drops the row by 176px between `pointerdown` and `pointerup` — the button slides out from under the finger and the click never lands. +### Setup is gone while a run is in flight + +`uiState.setupHidden` takes the panel off screen for the length of a run, and +both ways back into it with it — the HUD's Setup button and the docked panel's +own handle. Everything on its three tabs is an input to *that* run (the island +it is solving, the roster it was planned with, the timeline it is rating +against) and a run reads every one of them once, at launch, so a press there +either cannot reach the search at all or, on the island list, stops it outright. +Hidden rather than disabled, which also replaces a half-measure: +`SolveModeSelector` and `AnomalySelector` already greyed themselves out while +the island list and the roster beside them stayed live, so the panel was part +working and part dead with nothing saying which was which. Both keep their +`disabled` as a backstop for the frame between the run starting and the panel +unmounting. Stop is untouched, because it is in the HUD. + +Three things follow, and each is something that would otherwise be left behind: + +- **`ConfigSidebar` holds `uiState.sidebarWidth` for the run, and clears it + otherwise.** Its teardown is the same call `HudToolbar` makes for + `hudHeight` — a width left behind reserves canvas, and the HUD's own + `padding-left` tracks the same edge — and that is right for a preview or a + hidden interface, which are states the user is *in*. A run is not: Setup is + away for seconds and comes back on its own, so handing the space over means + taking it again a moment later, with the action pill gliding 190px out from + under the cursor that pressed RUN and back when it lands. Held, the whole + scene stays still and all the run changes is that the panel is not drawn + over it. The effect also skips the `0` that `bind:clientWidth` reports + before it has measured, which would otherwise drop the inset for one paint + every time Setup came back — and `padding-left` transitions, so one paint is + a visible glide out and straight back. +- **The sheet is shut on the way in**, in `#beginSolve`, the one route both Run + and the re-run dialog take to `runOptimizer`. Same reason `setUiHidden` shuts + it: a detent left open describes a panel that is no longer rendered, and on a + compact viewport it takes the whole HUD down with it — which is where STOP is. + In practice it is already closed, since the HUD unmounts while the sheet is + open and Run is in the HUD; the case it covers is a sheet opened on a phone + and then run from a window that has since been widened. +- **`dismissConfigPanel` sits the run out.** `collapseSidebar` persists, so a + press on the view toggle mid-run would otherwise hand the user back a panel + that had shut itself. + +**The Edit/Solver switch goes with it**, on `uiState.canSwitchBoards` — which +is `hasSolverPlacements` and no run in flight, and is what both the toggle and +the compact line it sits on render against. `showingSolver` keeps reading +`hasSolverPlacements` alone, since which board is drawn is a different question +from whether the switch is offered. Three reasons, all pointing the same way: +the readout is pinned to the run for the duration (`statsPanel`), so switching +to Edit leaves the card reporting the search while the canvas draws the user's +board — the one disagreement `visiblePlacements` exists to make impossible; the +switch to the solver's board is an edge on the run *starting* (`PixiCanvas`), so +a layout finishing while the user is on their own board lands where nobody is +looking; and on a first-ever run the pill would appear partway through anyway, +the moment the first progress report gives it something to switch to, which on +a phone is a line arriving in the HUD unasked. So mid-run on a compact viewport +the whole stack is the action pill alone. + +**Hidden there does not mean unmounted, and the difference is the wide-screen +row.** On a phone the toggle has a line of its own and the pill below is pinned +to the band's right edge, so the line is simply not rendered. On a wide screen +the toggle shares one centred row with the action pill, and dropping out of the +flow re-centres RUN/STOP under the cursor that just pressed it — so it renders +on `hasSolverPlacements`, keeps its box, and takes `visibility: hidden`, which +is the one declaration that gives up paint, hit testing and the accessibility +tree at once while leaving the layout alone. That is why the `.view-row` gate +and the toggle's own are deliberately not the same condition. + +On a compact viewport `.hud-top` is `justify-content: flex-end`, which is what +keeps the run pill still as Setup leaves. Setup's `margin-right: auto` takes the +free space first while it is there, so the property does nothing; with Setup +gone it is the whole of the rule, and STOP stays where the finger that pressed +RUN put it instead of centring itself across the row. + ### The HUD dismisses the config panel A `pointerdown` anywhere in the HUD stack closes the mobile sheet and folds the @@ -1357,20 +1950,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 @@ -1380,7 +1980,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 @@ -1522,6 +2122,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 @@ -1530,11 +2146,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-*` | `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 @@ -1554,6 +2178,96 @@ 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 `--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. + +**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. + +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..1af0344 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,452 @@ 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. + +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 + 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. + + **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 fourth tier under Infinite Grid level 4 (×1.7) then a crowded ×0.8 is 1.2036e22 + against 1.2036000000000001e22 for ×1.36, and the _order_ is observable too: the same tier under + ×1.25 then a ×1.67 shore is 1.8474375e22 where the reverse is 1.8474374999999997e22. 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. + +**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: + +- **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 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, 66.7% under a maxed Stellar Forge reactor — so the move stops firing on much of what + 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 +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 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. + +`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. + +**At full unlocks this anomaly is close to power-neutral, and that is the rule rather than the +search.** x4 scales a generator's _intake_, which is a capacity: fed by the reactors it already +had, a bonused generator fills to 25% and produces exactly what it did before. Only the penalty +bites, and the penalty is avoidable by keeping generators apart. Map 3 at 15s over three seeds comes +back 1.420e23 at best under both rules, where the one baseline layout whose generators touch +_re-rates_ under the anomaly to 1.182e23 — so the search recovers the whole penalty and there is no +gain to find. + +**On a roster where the generators are the short side it is not neutral at all, and the search had +to be told how to count.** `targetCompositions` is the one stage that decides _what_ to build, and +it counts: how many generators are worth their tile depends on what a generator is worth, which +under this rule is two numbers rather than one. Neither is in the pool the stage draws on — +`rateRole` is the identity here, because which of the two a tile gets is decided by the layout — +so the count it produced was the one for a roster nobody is playing. With everything unlocked but +generator7 at tier 1, Gale Hills plateaued at 43.2–45.2AC from 5s to **150s**: the walk found the +penalty-avoiding half and never the different mix, because no single tile change reaches it and the +one stage that proposes mixes was proposing the base-rules one. + +**Sizing at the bonus alone does not fix it.** It asks for 20 generators on an island that can keep +15 apart; `arrangeComposition` spreads them as far as they go and most still touch, so the target +is rejected for the layout already in hand and the stage again does nothing. The count has a ceiling the roster cannot see and the island can, and it is the one +the bound already computes — `generatorCapacityTable` builds `island.ts`'s own `generatorCapacities` +over `isolationRoom`, imported rather than restated, so there is one reading of what a board has +room for. Then the target asks for 16 — the block ceiling, against an exact 15 — and over ten runs +at 15s the mean goes from 44.6AC to **46.2AC** (+3.6%), the typical layout carrying 13 of its 17 +generators isolated. The best any run reaches, with the table or without, is 49.8AC: fifteen +generators, every one of them isolated. + +The gain is where the ceiling binds, and at generator7 tier 1 it binds on every shipped map. Over +three seeds at 15s the table is worth +12–14% on maps 7 and 8, +6.5% on map 4 and nothing on map +3, and costs 1–4% on maps 2, 5 and 6 — a net gain rather than a uniform one, and on boards that +small three seeds is thin. One tier higher the reactors are the short side, the split wants fewer +generators than the room allows, and nothing bends; at full unlocks the split wants 11% of the +island and every board has room. `islandRatingCeiling` takes a `sizedByIsolation` flag for the same reason the table +exists: a target sized through it already carries the isolated rating, and scaling it by the rating +again would wave through targets that cannot come close. Under every rule that does not rate by +isolation the table is null and the stage runs the multiply it always ran, so the fixtures — which +pass no anomaly — reproduce byte for byte. + +**The bound takes the anomaly too, and every rule of it.** `estimateTotalMaxPower` rates each +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 the generator-bound 3×3 in `island.test.ts` reads 267% against +a bound that ignores Singularity; 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 rates reactors and coolers ×4 as well: 3.8× the tight bound on map 1, so a near-optimal +layout reads as 25% layout efficiency, and on a generator-bound roster (where the bonus genuinely +pays, +88% over the base rules on map 3 at generator7 tier 1) 43% for a layout at 92% 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. + +**Which is sound but not tight, and the gap is adjacency.** Rating every generator ×4 let the LP +buy heat by spending _fewer_ tiles on generators, and past a point no board can deliver it: heat +crosses a tile boundary and nothing else, so a generator's intake is the output of the reactors +beside it, and the all-or-nothing cooling rule wants coolers beside it too — out of the same eight +tiles. One generator absorbing `H` needs `H / rVal` of them as reactors and `wasteRatio · H / cVal` +as coolers, so `neighbourHeatCap` is `8 / (1 / rVal + wasteRatio / cVal)` and `estimateIslandMaxPower` +runs on `min(gVal, cap)`. Every term in it is a _most_ — an adjacent reactor may be feeding another +generator — so it only ever errs high. On an island of fewer than nine tiles the count is the island +rather than eight. + +It is the one piece of adjacency the bound does not relax away, and it is here because it is the one +that binds. It is homogeneous of degree 1 in the roster exactly as the LP is, so a rule that scales +everything at once — a terrain bonus, a research — moves the cap and the generator it caps by the +same factor and it goes on not binding; at full unlocks the top generator sits at 72% of it under +the base rules. A rule that scales a _single role_ is what it takes, and Singularity is that rule: +×4 puts the top generator at 290% of the cap. Without it the shipped maps' bound sits 6–9% above +anything the board allows, reading 83–87% layout efficiency for layouts that are not 83–87% of +anything; with it they read 89.4–95.0%, against 93.5–98.5% under the base rules. Nothing else moved — +the base, Cryo and Tidal figures on all eight maps are unchanged to the last digit. Under a pool the +cooler term goes, because adjacency does: the pool reaches the whole island, so every neighbour may +be a reactor. The two-class bound takes one cap on the _best_ of each class, since which class a +tile falls in is a property of that tile and an inland generator may have shore reactors around it. + +**The second half of the same argument is how many generators an island can keep apart, and a +generator-bound roster is what runs into it.** Rating every generator at the bonus is what a search +free to spread them out can reach — but isolated generators are pairwise non-adjacent by +definition, so they are an independent set in the 8-neighbour graph, and when the generators are +the short side the split wants a third of the island to be one. Gale Hills at generator7 tier 1 was +asked for 19.3 where the island admits 15, and read 78% layout efficiency for the best layout any +run has found. + +A maximum independent set is NP-hard — an exact branch and bound takes 5.4M nodes on a 49-tile +island and does not finish on a 77-tile one — so `isolationRoom` reads the geometry off a **2×2 +block partition**, where two facts are free. All four tiles of a block are mutually adjacent, so a +block holds at most one isolated generator (16 against the exact 15 on Gale Hills, 21 against 20 on +Ash Bay); and a block holding one holds **no other generator at all**, which is what bounds the +crowded ones — the island less the blocks the isolated generators sit in, and putting them in the +_smallest_ blocks leaves the most room. Both are read at each of the four alignments and taken at +their tightest, pointwise: every alignment states a true constraint, so the strongest of them is +true as well. What a partition cannot see is adjacency _across_ a block boundary, so a crowded +generator in a free block is allowed here and often is not on the board — the bound errs high, as +it must. + +`crowdedRatedRoster` is the pair to `roleRatedRoster` that makes this expressible: the same roster +at the **lesser** of the two ratings, so `generatorCapacities` can bend at the ceiling instead of +running the bonus out to the whole island. Both are real ratings a layout can carry, so the +generator ratios are taken across the pair — the argument the two-class terrain bound makes, for +the same snapped-waste reason. Generator capacity is the one figure the LP counts per tile, so it +is the one a count limit can bend; a reactor, cooler or direct-producer isolation would each need +their own and none ships, so those keep the better rating alone, which is sound and loose. +`bestHeatForEngineTiles` scans every reactor/generator split rather than the two either side of the +crossing, because with the bend the peak is no longer there — O(tiles) inside a loop already +O(tiles), on a function called once per solve. + +At generator7 tier 1 with everything else full this moves all eight maps by 7–17% (Gale Hills +−16.9%, Magma Rift −16.1%) and the full-unlock figures not at all, since there the split wants 11% +of the island as generators and every board has room for that. + +The residue is geometry the LP cannot see, and it is not small on a generator-bound roster: the +bound relaxes reactor-to-generator and cooler-to-producer adjacency away, and under Singularity the +layout is _made of_ that adjacency. Gale Hills at generator7 tier 1 typically reads 86% (93% at +best) where the base rules read 91% on the same board. At full unlocks the ×4 buys a generator +capacity the layouts were never short of, and the search comes back within about 1% of the base +rules' power. + +`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 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 +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. + +**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. + +**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. + +**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 ×4 under Singularity. Scaling it by `islandRatingCeiling` was measured over seeds: +Tidal on map 3 at 15s went 176/178/179AC to 179/181/178AC, and Singularity at 30s over five seeds +on map 3 142/140/142/142/140AC to 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to +267/271/271/271/274AC — inside run-to-run noise 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 + +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. + +**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`. + +**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. + ### `distribution.ts` — a port of Unity's `FlowNetwork.cs` Two shapes in it are load-bearing and look like details: @@ -105,8 +552,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 @@ -245,13 +705,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 @@ -275,6 +744,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 9d7acc7..4e7656e 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,8 +369,307 @@ 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 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 + +Eleven researches exist; **three** of them change a building's figures, and the +other eight move research income, research time, Chronons, obstacle-removal +cost, energy sale price and building prices — all decided before a board is +laid out, so none can change which layout is best. + +| research | scales | levels (×) | +| ------------------ | --------------------------------------------- | ----------- | +| **Stellar Forge** | every heat producer's heat output | ×1.1 – ×1.5 | +| **Infinite Grid** | every generator's and wind turbine's heat intake, energy and overheat capacity | ×1.1 – ×2.0 | +| **Absolute Zero** | every cooler's cooling | ×1.1 – ×2.0 | + +Absolute Zero and Infinite Grid share one bonus curve — 0.1, 0.25, 0.45, 0.7, +1.0 — and Stellar Forge a shallower one — 0.1, 0.2, 0.3, 0.4, 0.5. The game +authors them as a bonus fraction and shows them as "+10%" through "+100%" (or +"+50%" for Stellar Forge); 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 + +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. + +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. + +### 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 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 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 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: + +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: + +``` + ← 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. 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 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 (×4) with Infinite Grid maxed (×2) is rated ×8. That is a +*rating*, not a promise of 8× the power: what it actually produces still +depends on how much heat reaches it. + +It is also not always the same double as one combined multiply, and the +difference is real rather than pedantic — though not at this pair, since ×2 and +×4 are powers of two and scale exactly. Infinite Grid at level 4 (×1.7) on a +crowded generator (×0.8) rates generator7's fourth tier at 1.2036e22 as two +successive scalings and 1.2036000000000001e22 as one ×1.36. The **order** +matters for the same reason — the same tier under Infinite Grid at level 2 +(×1.25) then a ×1.67 shore bonus is 1.8474375e22 where the reverse is +1.8474374999999997e22 — 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. + +### 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. + +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: + +- **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". +- **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 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 +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 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 + 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 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: + +- 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 + +> 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. + +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. + +**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 +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 ×4 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. + +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. 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 fb6d6dd..04f47e7 100644 --- a/packages/solver/bin/solve.ts +++ b/packages/solver/bin/solve.ts @@ -31,10 +31,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +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, @@ -46,6 +48,7 @@ import { solve, } from "../src/solver/solver"; import type { + AnomalyId, IslandLayout, OptimizationResult, Tile, @@ -55,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, @@ -101,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 { @@ -143,6 +151,7 @@ async function solveWithPool( unlockedUpgrades: Record, timeBudgetS: number, seed: number | undefined, + anomalyId: AnomalyId | undefined, pool: WorkerPool | null, ): Promise { const plan = planSolve( @@ -151,6 +160,7 @@ async function solveWithPool( unlockedUpgrades, timeBudgetS, seed, + anomalyId, ); if (!plan || plan.islands.length < 2 || !pool || pool.size < 2) return solve( @@ -158,7 +168,7 @@ async function solveWithPool( [...BUILDINGS], unlockedUpgrades, timeBudgetS, - undefined, + { anomalyId }, seed, ); @@ -176,6 +186,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) => { @@ -186,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, @@ -205,6 +255,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,13 +270,14 @@ async function solveMap( unlockedUpgrades, timeLimitS, offsetSeed(seed, runNum - 1), + anomalyId, pool, ); results.push({ runNum, result, elapsedS: (Date.now() - started) / 1000, - code: await codeFor(grid, result), + code: await codeFor(grid, result, anomalyId), }); } @@ -246,26 +298,29 @@ async function runSingle( unlockedUpgrades: Record, timeLimitS: number, seed: number | undefined, + anomalyId: AnomalyId | undefined, 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]; + 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( @@ -274,17 +329,19 @@ async function runComparison( runs: number, timeLimitS: number, seed: number | undefined, + anomalyId: AnomalyId | undefined, 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( @@ -295,10 +352,11 @@ async function runComparison( `Map ${gameMap.num} [${idx + 1}/${gameMaps.length}]`, filenameFor, seed, + anomalyId, 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, ); @@ -331,6 +389,7 @@ async function runSession( estimatedMaxPower: 0, topN: args.top, workers: inFlight, + anomalyId: args.anomalyId, powers: [], top: [], elapsedS: 0, @@ -361,6 +420,7 @@ async function runSession( unlockedUpgrades, timeLimitS: args.timeLimitS, seed: offsetSeed(args.seed, i), + anomalyId: args.anomalyId, }, })), (attempt, raw) => { @@ -379,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); @@ -427,10 +487,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,9 +510,15 @@ 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.`; +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 = { @@ -462,6 +530,7 @@ function parseArgs(argv: string[]): Args | null { top: DEFAULT_TOP_N, workers: undefined, seed: undefined, + anomalyId: undefined, }; const takeValue = ( @@ -533,6 +602,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 +676,7 @@ export async function main(argv: string[]): Promise { args.runs, args.timeLimitS, args.seed, + args.anomalyId, pool, ); else @@ -598,6 +685,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/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..ce40adf --- /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.", + coolerMultiplier: 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 ×4", + drawback: "Adjacent generators ×0.8", + description: + "A Generator with no other Generator next to it gets ×4 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: 4, + 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..f5ac94c 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,94 @@ 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 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. + * + * **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. + */ +export function scaleEffectiveBuilding( + building: EffectiveBuilding, + factor: number, +): EffectiveBuilding { + if (factor === 1) return building; + + const effectiveValue = building.effectiveValue * factor; + const energy = building.energy * factor; + return { - id: def.id, - type: def.type, - effectiveValue: baseValue, - energy: 0, - waste: 0, + id: building.id, + type: building.type, + 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`. + 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..861d56e --- /dev/null +++ b/packages/solver/src/data/prestige.ts @@ -0,0 +1,144 @@ +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 eleven are here.** The other eight 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_exchange` — energy sale price + * - `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: [0.1, 0.25, 0.45, 0.7, 1.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.", + // 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 x2 makes x2 the waste and needs x2 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: [0.1, 0.25, 0.45, 0.7, 1.0], + }, + { + id: "stellar_forge", + name: "Stellar Forge", + effect: "Heat Output", + description: "Boosts the Heat output of all Heat Producers.", + /* + * 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. 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. + * + * 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: [0.1, 0.2, 0.3, 0.4, 0.5], + }, +]; + +/** 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..39d5bae 100755 --- a/packages/solver/src/encoding/blueprint.ts +++ b/packages/solver/src/encoding/blueprint.ts @@ -3,17 +3,38 @@ 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. * * 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 +69,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 + * ×2-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 +102,75 @@ 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. + * + * 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 = { + 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 +258,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 +347,7 @@ function buildPayload( grid: Tile[][], placements: readonly BlueprintPlacement[], tiers?: BlueprintTiers, + rules?: BlueprintRules, ): Uint8Array { const height = grid.length; const width = grid[0].length; @@ -258,15 +385,54 @@ 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)))); + } + /* + * 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 as 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 +467,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 +502,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 +523,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 +562,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 +584,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 +596,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 }; } /** @@ -424,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/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..a74a3a2 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -1,5 +1,14 @@ +import { getAnomaly } from "../data/anomalies"; +import { scaleEffectiveBuilding } from "../data/effectiveBuildings"; import { CHEBYSHEV_DIRECTIONS, spatialKeyCompare } from "./constants"; -import type { Tile } from "./types"; +import { computeWaterAdjacency } from "./island"; +import type { + AnomalyDefinition, + BuildingType, + EffectiveBuilding, + Tile, + TileType, +} from "./types"; /** * Scratch buffers reused by every `runDistribution` call on one island. @@ -100,6 +109,98 @@ 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. + * + * 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 `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 — 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; + /** + * 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; + /** + * 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. + * + * 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. */ @@ -121,12 +222,112 @@ export interface IslandContext { readonly wasteTiles: Int32Array; } -/** Builds the tile index and adjacency for one island's grass tiles. */ -export function buildIslandContext(localGrid: Tile[][]): IslandContext { +/** 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. + * + * 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. + * + * `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. + * + * `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][] = []; 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); @@ -163,8 +364,108 @@ export function buildIslandContext(localGrid: Tile[][]): IslandContext { neighbors[i] = Int32Array.from(found); } + const tileScale = terrainScales( + localGrid, + anomaly, + xs, + ys, + 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; + // 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 + // 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 + // 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(); + + /** + * `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, + ); + }, + 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 + // 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, neighbors, diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index 05e398e..ec61de9 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -5,7 +5,14 @@ import { GENERATOR_WASTE_RATIO, } from "./constants"; import { generatorEnergyRatio, generatorWasteRatio } from "./physics"; -import type { EffectiveBuilding, IslandSubGrid, Tile } from "./types"; +import { scaleEffectiveBuilding } from "../data/effectiveBuildings"; +import type { + AnomalyDefinition, + EffectiveBuilding, + IslandSubGrid, + TerrainAffinityAnomaly, + Tile, +} from "./types"; /** * Island decomposition and per-island upper bounds. @@ -45,19 +52,146 @@ export function canCoolDirectProducer(roster: EffectiveBuilding[]): boolean { return false; } +/** + * The fewest tiles a patch needs before anything on it can be worth building. + * + * 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. + * + * 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): number { + 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; +} + +/** + * 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 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 []; + // 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 = canCoolDp ? 2 : 3; + const minTilesRequired = minIslandTiles(canCoolDp); + const waterAdjacent = computeWaterAdjacency(grid); // 1. Only grass is buildable; everything else is an impassable boundary. const buildable = new Uint8Array(originalWidth * originalHeight); @@ -114,31 +248,46 @@ 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[][] = []; const originalTileIndices = new Int32Array(subWidth * subHeight); + const subWaterAdjacent = new Uint8Array(subWidth * subHeight); 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", - }); + 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. + row.push({ x: subX, y: subY, type: grid[origY][origX].type }); } subGridTiles.push(row); } @@ -147,6 +296,9 @@ export function splitGridIntoIslands( width: subWidth, height: subHeight, grid: subGridTiles, + buildable: inComponent, + waterAdjacent: subWaterAdjacent, + tileCount: component.length, originalTileIndices, }); } @@ -155,17 +307,81 @@ 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% * (the earlier per-hub density estimate ignored cross-hub sharing of reactors * and coolers, which real layouts exploit, and was routinely beaten by 3-50%). * - * The bound relaxes adjacency away entirely and asks only what the tile COUNTS - * allow. For every split of the island into `nEngine` tiles (reactors + - * generators), `nDp` direct producers and `nCool` coolers, any layout obeys + * The bound relaxes adjacency away almost entirely and asks what the tile + * COUNTS allow — the exception is `neighbourHeatCap`, which is the one place + * adjacency binds hard enough to matter. For every split of the island into + * `nEngine` tiles (reactors + generators), `nDp` direct producers and `nCool` + * coolers, any layout obeys * - * heat absorbed H <= min(nReact * R_top, nGen * G_top) + * heat absorbed H <= min(nReact * R_top, nGen * min(G_top, neighbour cap)) * online DPs D <= nDp * total cooling 0.25 * H + wMin * D <= nCool * C_top * @@ -182,42 +398,29 @@ export function splitGridIntoIslands( function estimateIslandMaxPower( island: IslandSubGrid, roster: EffectiveBuilding[], + pooledCooling: boolean, + crowdedRoster: EffectiveBuilding[] | null = null, ): number { - const buildableTiles = countGrassTiles(island.grid); + 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; + const figures = rosterFigures(roster); + const { cVal, rVal, gVal, dpNet, hasCooler, hasDp } = figures; + let { dpWaste, energyRatio, wasteRatio } = figures; - 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; - } + // The same roster at the *other* rating a role isolation offers, or null + // under every other rule. Both are real ratings a layout can carry, so the + // ratios a generator is bounded through are taken across the pair — the + // argument `estimateMixedIslandMaxPower` makes for its two classes, and for + // the same reason: a re-derived waste ratio is snapped, so the two are not + // bit-identical and a bound has to allow the greedier. + const crowdedFigures = + crowdedRoster === null ? null : rosterFigures(crowdedRoster); + if (crowdedFigures !== null) { + if (crowdedFigures.energyRatio > energyRatio) + energyRatio = crowdedFigures.energyRatio; + if (crowdedFigures.wasteRatio < wasteRatio) + wasteRatio = crowdedFigures.wasteRatio; } if (!hasCooler || cVal <= 0) return 0.0; @@ -226,7 +429,40 @@ function estimateIslandMaxPower( if (wasteRatio === Infinity || wasteRatio <= 0) wasteRatio = GENERATOR_WASTE_RATIO; - const canHub = rVal > 0 && gVal > 0 && buildableTiles >= 3; + // What a generator tile is worth to the bound: its tier, or the most its + // eight neighbours can push through it, whichever is less. Both ratings a + // role isolation offers are capped, and for the same reason — the bonus one + // most of all, since it is the one that outruns a neighbourhood. + const neighbourCap = neighbourHeatCap( + maxNeighbourTiles(buildableTiles), + rVal, + cVal, + wasteRatio, + pooledCooling, + ); + const gTake = Math.min(gVal, neighbourCap); + const gCrowdedTake = + crowdedFigures === null + ? gTake + : Math.min(crowdedFigures.gVal, neighbourCap); + const capacities = generatorCapacities( + buildableTiles, + gTake, + gCrowdedTake < gTake + ? { gTake: gCrowdedTake, room: isolationRoom(island) } + : null, + ); + + // 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 && gTake > 0 && buildableTiles >= 3; const canDp = hasDp && buildableTiles >= 2; if (!canHub && !canDp) return 0.0; @@ -234,7 +470,7 @@ function estimateIslandMaxPower( const maxEngine = canHub ? buildableTiles : 0; for (let nEngine = 0; nEngine <= maxEngine; nEngine++) { - const heatCap = bestHeatForEngineTiles(nEngine, rVal, gVal); + const heatCap = bestHeatForEngineTiles(nEngine, rVal, capacities); // Engine tiles that can't form a reactor+generator pair. if (nEngine > 0 && heatCap <= 0.0) continue; @@ -270,36 +506,621 @@ function estimateIslandMaxPower( return best; } +/** + * The most heat one generator tile can take in and still run, given how many + * neighbours it has to take it from. + * + * The rest of the bound relaxes adjacency away, and on the base rules that + * costs almost nothing. This is the one place it cannot: **heat crosses a tile + * boundary and nothing else**, so a generator's intake is the output of the + * reactors beside it, and the all-or-nothing cooling rule then says it only + * counts if the coolers beside it cover the waste that intake makes. Both + * claims are on the same eight tiles, so one generator absorbing `H` needs + * `H / rVal` of them as reactors and `wasteRatio * H / cVal` as coolers, and + * the two together cannot outrun the neighbourhood: + * + * H <= maxNeighbours / (1 / rVal + wasteRatio / cVal) + * + * Every term is a *most* — an adjacent reactor may be feeding another + * generator, an adjacent cooler another producer — so the inequality only ever + * errs high, which is what a bound needs. + * + * It is homogeneous of degree 1 in the roster, exactly as the LP below is, so a + * rule that scales everything uniformly (a terrain bonus, a research) can never + * make it bite: the cap rises with the generator it caps. A rule that scales + * **one role** breaks that, and `role_isolation` is the one that does — + * Singularity Isolation rates a lone generator's intake x4 while leaving the + * reactors that fill it and the coolers that clear it alone, so the LP bought + * its extra heat by spending fewer tiles on generators than any arrangement of + * eight neighbours can serve. Without the cap the shipped maps' bound sits 6-9% + * above anything the board allows, and the efficiency figure reads 83-87% for + * layouts that are not 83-87% of anything. + * + * Under a shared cooling pool the cooler term is gone, because adjacency is: + * the pool reaches the whole island, so every neighbour may be a reactor. + */ +function neighbourHeatCap( + maxNeighbours: number, + rVal: number, + cVal: number, + wasteRatio: number, + pooledCooling: boolean, +): number { + if (maxNeighbours <= 0 || rVal <= 0) return 0.0; + const perUnitHeat = + 1 / rVal + (pooledCooling || cVal <= 0 ? 0 : wasteRatio / cVal); + return maxNeighbours / perUnitHeat; +} + +/** How many tiles can touch one building here: eight, or the island if smaller. */ +function maxNeighbourTiles(buildableTiles: number): number { + return Math.min(CHEBYSHEV_DIRECTIONS.length, buildableTiles - 1); +} + +/** + * How much of the `isolated` rating an island can actually carry. + * + * `role_isolation` rates a generator with no generator beside it far above one + * with, and the LP's optimum under Singularity asks for more of them than any + * board can hold: isolated generators are pairwise non-adjacent by definition, + * so they are an independent set in the 8-neighbour graph — and on a roster + * whose generators are the short side, the split wants a third of the island to + * be generators, every one of them isolated. Gale Hills at generator7 tier 1 + * was asked for 19.3 where the island admits 15, and read 78% layout efficiency + * for the best layout any run has found. + * + * A maximum independent set is NP-hard in general, so this reads the geometry + * off a **2x2 block partition**, where two facts are free: + * + * - All four tiles of a 2x2 block are mutually adjacent, so a block holds at + * most one isolated generator — `blocks` is therefore a ceiling on how many + * there can be. On the shipped islands it lands within one or two of the + * exact figure (16 against 15 on Gale Hills, 21 against 20 on Ash Bay). + * - A block that holds one holds **no other generator at all**, for the same + * reason. So `freeTiles[k]` — the tiles left for crowded generators once `k` + * are isolated — is the island less the `k` blocks they sit in, and putting + * them in the *smallest* blocks is what leaves the most room. + * + * Both are read for each of the four alignments of the block grid and taken at + * their tightest, pointwise: every alignment states a true constraint, so the + * strongest of them is true as well. What the partition cannot see is + * adjacency *across* a block boundary, which is why a crowded generator in a + * free block is allowed here and often is not on the board — the bound errs + * high, as it must. + */ +export interface IsolationRoom { + /** Most generators on this island that can be rated `isolated` at once. */ + readonly maxIsolated: number; + /** Tiles that may still hold a crowded generator, by isolated count. */ + readonly freeTiles: Int32Array; +} + +export function isolationRoom(island: IslandSubGrid): IsolationRoom { + const tiles = island.tileCount; + let maxIsolated = tiles; + const freeTiles = new Int32Array(tiles + 1).fill(tiles); + + for (let ox = 0; ox <= 1; ox++) { + for (let oy = 0; oy <= 1; oy++) { + const sizes = new Map(); + for (let y = 0; y < island.height; y++) { + for (let x = 0; x < island.width; x++) { + if (island.buildable[y * island.width + x] !== 1) continue; + const block = (((y + oy) >> 1) << 16) + (((x + ox) >> 1) & 0xffff); + sizes.set(block, (sizes.get(block) ?? 0) + 1); + } + } + + const ascending = [...sizes.values()].sort((a, b) => a - b); + if (ascending.length < maxIsolated) maxIsolated = ascending.length; + + // Tiles the isolated generators' own blocks take with them, smallest + // blocks first — the arrangement that leaves the most room behind. + let consumed = 0; + for (let k = 0; k <= tiles; k++) { + if (k > 0 && k <= ascending.length) consumed += ascending[k - 1]; + const free = k <= ascending.length ? tiles - consumed : 0; + if (free < freeTiles[k]) freeTiles[k] = free; + } + } + } + + return { maxIsolated, freeTiles }; +} + +/** + * The most heat `nGen` generator tiles can absorb, by count. + * + * A table rather than a multiply, because under a rule that rates by isolation + * capacity is not linear in the tile count. `gTake` is what a generator is + * worth at the better of the two ratings and `crowded` what it is worth at the + * other; each generator taking the better rating spends a whole 2x2 block, so + * past a point one more of them costs more capacity than it brings, and which + * side of that point an island is on is what the scan settles. + * + * `crowded` is null under every other rule, and then this is the multiply it + * always was. + */ +export function generatorCapacities( + buildableTiles: number, + gTake: number, + crowded: { gTake: number; room: IsolationRoom } | null, +): Float64Array { + const capacities = new Float64Array(buildableTiles + 1); + if (crowded === null) { + for (let nGen = 0; nGen <= buildableTiles; nGen++) + capacities[nGen] = nGen * gTake; + return capacities; + } + + const { room } = crowded; + for (let nGen = 0; nGen <= buildableTiles; nGen++) { + let best = 0.0; + const mostIsolated = Math.min(nGen, room.maxIsolated); + for (let nIso = 0; nIso <= mostIsolated; nIso++) { + const rest = nGen - nIso; + if (rest > room.freeTiles[nIso]) continue; + const capacity = nIso * gTake + rest * crowded.gTake; + if (capacity > best) best = capacity; + } + capacities[nGen] = best; + } + return capacities; +} + /** * Most heat `nEngine` tiles of reactors + generators can hand over, cooling - * ignored: max over nReact of `min(nReact * rVal, (nEngine - nReact) * gVal)`. - * The min of two crossing lines peaks where they intersect, so only the two - * integer counts around the crossing need checking. + * ignored: max over nReact of `min(nReact * rVal, capacities[nEngine - nReact])`. + * + * Every split is checked rather than the two either side of where the two lines + * cross. The crossing is only where the peak is while generator capacity is + * linear in the tile count, and under a rule that rates by isolation it is not + * — `generatorCapacities` bends once the island runs out of room to keep them + * apart. The scan is O(tiles) inside a loop that is already O(tiles), on a + * function called once per solve rather than inside the search. */ function bestHeatForEngineTiles( nEngine: number, rVal: number, - gVal: number, + capacities: Float64Array, +): number { + if (nEngine < 2 || rVal <= 0) return 0.0; + + let best = 0.0; + for (let nReact = 1; nReact <= nEngine - 1; nReact++) { + const heat = Math.min(nReact * rVal, capacities[nEngine - nReact]); + if (heat > best) best = heat; + } + return best; +} + +/** + * `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 || rVal <= 0 || gVal <= 0) return 0.0; + if (nEngine < 2) return 0.0; - const crossing = (nEngine * gVal) / (rVal + gVal); + const inlandEngine = nEngine - shoreEngine; + const slope = inland.rVal + inland.gVal; let best = 0.0; - for (const nReact of [Math.floor(crossing), Math.floor(crossing) + 1]) { - if (nReact >= 1 && nReact <= nEngine - 1) { - const heat = Math.min(nReact * rVal, (nEngine - nReact) * gVal); + 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; } -/** Sum of theoretical max power across all valid island sub-grids. */ +/** + * `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, + pooledCooling: boolean, +): 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; + + // One cap for both classes, on the strongest neighbour either class can + // offer: which class a tile falls in is a property of *that* tile, and an + // inland generator is perfectly free to have shore reactors and shore + // coolers around it. Taking the best of each is the only reading that stays + // a bound, and it costs nothing — the cap is homogeneous, so a terrain + // multiplier moves it and the generator it caps by the same factor and it + // goes on not binding. See `neighbourHeatCap`. + const gTake = neighbourHeatCap( + maxNeighbourTiles(buildableTiles), + Math.max(inland.rVal, shore.rVal), + Math.max(inland.cVal, shore.cVal), + wasteRatio, + pooledCooling, + ); + inland.gVal = Math.min(inland.gVal, gTake); + shore.gVal = Math.min(shore.gVal, gTake); + + // 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 the isolation bonus under Singularity, which rated + * reactors and coolers up as well — at x4 that is 3.8x the tight bound, so a + * near-optimal layout reads as 25% 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, + ); + } + } +} + +/** + * The same roster at the **lesser** of a role isolation's two ratings, or null + * when there is no second rating to hold. + * + * `roleRatedRoster` above takes the better of the two, which is sound — a + * search free to keep generators apart rates every one of them at the bonus — + * and on a generator-bound roster it is also a layout no board can hold: + * `isolationRoom` is the ceiling on how many may be apart at once, and past it + * the rest are rated at this. The pair is what lets `estimateIslandMaxPower` + * bend its generator capacity at that ceiling instead of running the better + * rating out to the whole island. + * + * Only the generator role, deliberately. Generator capacity is the one figure + * the LP counts per tile, so it is the one a count limit can bend; a reactor, + * a cooler or a direct producer would each need their own, and none of them + * ships. Every role stays sound either way — the better rating alone is still + * an upper bound, which is exactly what the other three keep. + */ +function crowdedRatedRoster( + roster: EffectiveBuilding[], + anomaly?: AnomalyDefinition, +): EffectiveBuilding[] | null { + if (anomaly?.rule !== "role_isolation" || anomaly.role !== "generator") + return null; + + const factor = Math.min(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 + * 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 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 + * 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 === undefined) return 1; + + switch (anomaly.rule) { + case "baseline": + case "shared_cooling": + case "role_isolation": + // Uniform across the island, so carried by `roleRatedRoster` instead. + return 1; + + case "terrain_affinity": { + if (anomaly.multiplier <= 1) 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[], + crowdedRoster: EffectiveBuilding[] | null, + anomaly?: AnomalyDefinition, +): number { + // The one rule `neighbourHeatCap` has to be told about: pooled cooling + // reaches the whole island, so a generator under it needs no cooler beside + // it and the cap is the reactor term alone. + const pooledCooling = anomaly?.rule === "shared_cooling"; + + const plain = estimateIslandMaxPower( + island, + roster, + pooledCooling, + crowdedRoster, + ); + 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; + // `plain` carries the cap on the unrated roster, and the cap is homogeneous, + // so scaling the whole figure scales the cap with it — the same reason the + // rest of the LP survives being scaled after the fact. + if (rated === null || rated >= island.tileCount) return plain * scale; + + return Math.min( + plain * scale, + estimateMixedIslandMaxPower( + island.tileCount, + rated, + roster, + scale, + pooledCooling, + ), + ); +} + +/** + * 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. + */ export function estimateTotalMaxPower( islands: IslandSubGrid[], roster: EffectiveBuilding[], + anomaly?: AnomalyDefinition, ): number { + const rated = roleRatedRoster(roster, anomaly); + const crowded = crowdedRatedRoster(roster, anomaly); let total = 0; - for (const island of islands) total += estimateIslandMaxPower(island, roster); + for (const island of islands) + total += estimateIslandBound(island, rated, crowded, anomaly); return total; } diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index 42b4672..352cf82 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -6,11 +6,13 @@ import { wasteIsCovered, } from "./physics"; import { buildIslandContext, type IslandContext } from "./context"; +import { generatorCapacities, isolationRoom } from "./island"; import { Pacer, type IslandProgress } from "./pacer"; import { AlternateCollector, isDistinctLayout, powerTies } from "./alternates"; import { Rng, randomSeed } from "./rng"; import { simulateIsland, type SimPlacedBuilding } from "./simulate"; import type { + AnomalyDefinition, EffectiveBuilding, IslandLayout, IslandSubGrid, @@ -70,6 +72,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; @@ -113,6 +124,53 @@ function emptyPlacement(n: number): Placement { return new Array(n).fill(null); } +/** + * Writes a building onto a tile, rated for that tile. + * + * The one way a building enters a placement, so that a terrain bonus cannot be + * missed at one of three dozen sites — and a miss would be silent, since the + * layout would simply be worth less than it is. `ctx.rate` is the identity + * under every rule but a terrain bonus, and idempotent under that one, so this + * is equally correct for a fresh placement, a swap between two tiles, and the + * restore that undoes a rejected move. + * + * Clearing a tile stays a plain `= null`: a null carries no rating, so there is + * nothing to resolve and nothing to get wrong. + */ +function put( + placement: Placement, + ctx: IslandContext, + tile: number, + building: EffectiveBuilding | null, +): void { + placement[tile] = ratedFor(ctx, tile, building); +} + +/** + * Exactly what `put` would write onto `tile` — the building as that tile rates + * it, or `null`. + * + * This is how a move asks "would writing this change anything?", and it has to + * be asked in the tile's own units: a placement holds RATED buildings, so + * comparing a plain roster entry against one is never equal on a scaled tile, + * and the guard that should have skipped the move instead writes back an + * identical object, runs a full `simulateIsland`, and hands `accept` a delta of + * zero — which it "accepts". No number moves, and the walk quietly spends a + * slice of a budget whose only product is steps per second. + * + * The comparison is exact rather than approximate because `ctx.rate` caches one + * object per (scale, roster entry) pair, so two tiles of the same scale holding + * the same building hold the *same* object — and it is cheap for the same + * reason: a lookup, not a multiply. + */ +function ratedFor( + ctx: IslandContext, + tile: number, + building: EffectiveBuilding | null, +): EffectiveBuilding | null { + return building === null ? null : ctx.rate(tile, building); +} + function copyInto(dst: Placement, src: Placement): void { for (let i = 0; i < dst.length; i++) dst[i] = src[i]; } @@ -193,6 +251,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, + * 66.7% under a maxed Stellar Forge. The move then stops firing on much of what + * it exists for, with no number anywhere disagreeing. It is a named function so + * that the reading can be pinned by a test rather than only stated here. + */ +function isUnderFed(row: SimPlacedBuilding): boolean { + return row.heatProduced > EPS && row.heatProduced < row.ratedValue - EPS; +} + /** Allocation-free `offlineProducers(...).length > 0`, for the search's inner loops. */ function anyOfflineProducer( placement: Placement, @@ -252,6 +327,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( @@ -281,7 +358,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; @@ -295,6 +372,7 @@ interface DpFit { dp: EffectiveBuilding; c: number; power: number; + waste: number; } function bestDpFit( @@ -309,7 +387,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; } @@ -339,6 +417,7 @@ interface SharedFit { reactorTiers: EffectiveBuilding[]; genPositions: number[]; coolerPositions: number[]; + waste: number; } /** @@ -355,11 +434,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; @@ -426,24 +523,28 @@ 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); } + if (pooled) { + put(scratch, ctx, poolTile, cooler); + touched.push(poolTile); + } const { totalPower } = simulateIsland(scratch, ctx); for (const n of touched) scratch[n] = null; @@ -465,6 +566,7 @@ function bestSharedReactorFit( reactorTiers, genPositions, coolerPositions, + waste: wasteTotal, }; } } @@ -502,6 +604,7 @@ function constructSeed( coolers: EffectiveBuilding[], directProducers: EffectiveBuilding[], deadlineMs: number, + pooled = false, ): Placement { const placement = emptyPlacement(ctx.n); const available = new Uint8Array(ctx.n); @@ -518,6 +621,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); @@ -525,7 +662,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; @@ -534,7 +671,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; @@ -558,12 +695,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, @@ -573,11 +715,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, @@ -592,18 +734,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, @@ -631,39 +774,63 @@ 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!; - 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; + 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) { - 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; + put(placement, ctx, best.neighbors[i], topCooler); claimed.push(best.neighbors[i]); } } @@ -681,6 +848,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; @@ -696,71 +910,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; - } - - if (performance.now() >= deadlineMs - 50 || buildable.length < 6) - return bestSeed; + 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; + } - 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 || buildable.length < 6) + return { seed: bestSeed, power: bestPower }; - 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; + } - return bestSeed; + if (performance.now() < deadlineMs - 50) { + const rng = new Rng(42); + const shuffled = [...buildable]; + rng.shuffle(shuffled); + const seedRand = constructSeed( + shuffled, + ctx, + reactors, + generators, + coolers, + directProducers, + deadlineMs, + pooled, + ); + const powerRand = simulateIsland(seedRand, ctx).totalPower; + if (powerRand > bestPower + EPS) { + bestPower = powerRand; + bestSeed = seedRand; + } + } + + return { seed: bestSeed, power: bestPower }; + }; + + const local = passes(false); + if (ctx.anomaly.rule !== "shared_cooling") return local.seed; + + // Under a pool the same three orders are run engine-first as well, and the + // strongest seed of the six starts the search. The local passes stay in: + // they are what the search started from before the pooled ones existed, and + // a pooled seed that comes back weaker is not an improvement. + const pooled = passes(true); + return pooled.power > local.power + EPS ? pooled.seed : local.seed; } // --------------------------------------------------------------------------- @@ -886,6 +1119,15 @@ 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. Scaling it by `islandRatingCeiling` was measured: + // Tidal on map 3 at 15s over three seeds went 176/178/179AC to 179/181/178AC, + // and Singularity (x4) at 30s over five seeds on map 3 142/140/142/142/140AC to + // 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to + // 267/271/271/271/274AC — inside run-to-run noise, so the plain figure is kept. + // Anyone re-measuring should use a longer budget and more seeds than that. const moveScale = generators.length > 0 ? generators[0].effectiveValue * generatorEnergyRatio(generators[0]) @@ -959,7 +1201,7 @@ async function hillClimb( ) { uncooled.push(p); } - if (p.heatProduced > EPS && p.heatProduced < p.baseValue - EPS) { + if (isUnderFed(p)) { wastedReactors.push(p); } } @@ -967,14 +1209,23 @@ 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]; - if (old !== topCooler) { - current[tile] = 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)) - current[tile] = old; + put(current, ctx, tile, old); continue; } } @@ -988,11 +1239,11 @@ async function hillClimb( if (adj.length > 0) { const tile = adj[rng.int(adj.length)]; const old = current[tile]; - if (old !== topGenerator) { - current[tile] = topGenerator; + if (ratedFor(ctx, tile, topGenerator) !== old) { + 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; } } @@ -1012,15 +1263,24 @@ 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; + } - 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; } @@ -1028,9 +1288,12 @@ 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; - current[tile] = replacement; + put(current, ctx, tile, replacement); const trial = simulateIsland(current, ctx); if (!accept(trial.totalPower, trial.placements)) current[tile] = old; } @@ -1070,6 +1333,7 @@ function targetCompositions( reactors: EffectiveBuilding[], generators: EffectiveBuilding[], coolers: EffectiveBuilding[], + generatorCapacity: Float64Array | null, topK = COMPOSITION_TARGETS, ): ScoredComposition[] { if ( @@ -1094,7 +1358,9 @@ function targetCompositions( const wasteRatio = generatorWasteRatio(generator); if (wasteRatio <= 0) continue; const budget = Math.min( - nGen * generator.effectiveValue, + generatorCapacity === null + ? nGen * generator.effectiveValue + : generatorCapacity[nGen], (nCool * cooler.effectiveValue) / wasteRatio, ); @@ -1150,6 +1416,135 @@ function targetCompositions( return scored.slice(0, topK); } +/** + * How much heat `nGen` generator tiles on THIS island can take in, or null + * under every rule that leaves the count alone. + * + * `targetCompositions` decides what to build by counting, and the count it + * wants is a function of what a generator is worth. Under a role isolation + * that is two numbers rather than one — a generator with no generator beside + * it takes 4x, one with 0.8x — and which a tile gets is decided by the + * layout, so neither belongs in the pool the stage draws on. The roster cannot + * express it; the island can, and `isolationRoom` is how much of the bonus its + * shape has room for. + * + * Both halves are load-bearing. Sizing at the bonus alone asks Gale Hills at + * generator7 tier 1 for 20 generators on an island that can keep 15 apart, and + * `arrangeComposition` cannot spread that many without most of them touching. + * With the room folded in it asks for 16, and over ten runs at 15s the mean + * goes 44.6AC to 46.2AC; the best either reaches is 49.8AC, fifteen generators + * every one isolated. Over three seeds it is worth +12-14% on maps 7 and 8 and + * costs 1-4% on maps 2, 5 and 6 — a net gain, not a uniform one. + * + * The same table the bound runs on (`island.ts`), and deliberately so — one + * reading of what a board has room for, rather than a second one here to drift + * against it. It is built from the island's own ratings rather than the + * capped figures the bound uses: a bound may relax adjacency away, a target + * has to be something the board can hold. + */ +function generatorCapacityTable( + island: IslandSubGrid, + ctx: IslandContext, + generator: EffectiveBuilding | undefined, +): Float64Array | null { + if (generator === undefined) return null; + + const isolated = ctx.rateIsolated(generator, false).effectiveValue; + const crowded = ctx.rateIsolated(generator, true).effectiveValue; + // Equal under every other rule, and under one that rates a crowded building + // no lower there is no ceiling to respect: the count is linear again. + if (crowded >= isolated) return null; + + return generatorCapacities(ctx.n, isolated, { + gTake: crowded, + room: isolationRoom(island), + }); +} + +/** + * The largest factor anything on this island can be rated at — 1 under the rules + * that leave the roster alone. + * + * `targetCompositions` scores a target from the plain roster, and it is right to: + * which buildings to use is a counting problem over the roster, and the counting + * is the same whatever the tiles are worth. But the layout the caller compares it + * against has been rated tile by tile, so the two figures are in different units, + * and a target's ceiling read as-is says "this cannot beat what I have" about + * every target on the list. The gate is a `break`, so the whole stage goes with + * it — silently, and hardest under the anomaly that most needs it: on island3 + * under Tidal a 0.6s run already returns 1.68e23 against a top target of + * 1.49e23, so the stage never ran at any realistic budget. + * + * Scaling the target by one island-wide factor is the move `estimateTotalMaxPower` + * makes to keep its bound a bound wherever its mask cannot say which tiles carry + * the factor (`islandMaxScale`), and it is sound for the same reason: a + * composition's power is positively homogeneous of degree 1 in + * the roster's figures, so rating the whole island at its best tile is an upper + * bound on rating each tile at its own. Loose where only part of an island + * qualifies, which is the right way to be wrong here — a ceiling that is too low + * skips a stage that would have helped, while one that is too high costs a few + * arrangement attempts that fail to beat the layout in hand. + * + * It is asked of the context rather than read off the anomaly so that a rule + * resolved per tile (`rate`) and one resolved per layout (`rateIsolated`) are + * both answered by the code that actually applies them, and a fifth rule shape + * needs nothing here at all. + */ +function islandRatingCeiling( + ctx: IslandContext, + probes: readonly EffectiveBuilding[], + sizedByIsolation: boolean, +): number { + if (ctx.uniformRating && (ctx.isolation === null || sizedByIsolation)) + return 1; + + let ceiling = 1; + const consider = (factor: number): void => { + if (factor > ceiling) ceiling = factor; + }; + + for (const probe of probes) { + if (probe.effectiveValue <= 0) continue; + if (!ctx.uniformRating) { + for (let tile = 0; tile < ctx.n; tile++) { + consider(ctx.rate(tile, probe).effectiveValue / probe.effectiveValue); + } + } + if (ctx.isolation !== null && !sizedByIsolation) { + // Both variants, because which one a tile gets is a function of the + // layout: a search free to keep generators apart rates every one of them + // at the bonus. The same reasoning `islandMaxScale` carries. + consider( + ctx.rateIsolated(probe, false).effectiveValue / probe.effectiveValue, + ); + consider( + ctx.rateIsolated(probe, true).effectiveValue / probe.effectiveValue, + ); + } + } + return ceiling; +} + +/** + * Whether a composition target could still beat the layout in hand, which is what + * decides whether the retarget stage runs at all. + * + * `target.power` is the power a perfect arrangement of the target would reach at + * the PLAIN roster's figures and `bestPower` is what a rated layout actually + * scored, so the ceiling is what puts the two in the same units — see + * `islandRatingCeiling`. The caller's gate is a `break` over a descending list, + * so answering `false` once ends the stage: a named function so the reading can + * be pinned, since it is the difference between a stage that runs and one that is + * silently never entered. + */ +function targetCanBeat( + target: ScoredComposition, + bestPower: number, + ratingCeiling: number, +): boolean { + return target.power * ratingCeiling > bestPower + EPS; +} + function sameFill(a: EffectiveBuilding[], b: EffectiveBuilding[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; @@ -1228,7 +1623,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; @@ -1240,7 +1635,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) { @@ -1252,9 +1647,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; @@ -1262,7 +1657,7 @@ async function arrangeComposition( } } if (bestMove < 0) return null; - current[bestMove] = building; + put(current, ctx, bestMove, building); } const now = performance.now(); @@ -1310,13 +1705,17 @@ 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]; - current[a] = valB; - current[b] = valA; + // Swapping identical buildings changes nothing — asked in each tile's own + // units, so two scale classes holding the same roster entry still count + // as identical. See `ratedFor`. + if (ratedFor(ctx, a, valB) === valA && ratedFor(ctx, b, valA) === valB) { + continue; + } + + put(current, ctx, a, valB); + put(current, ctx, b, valA); const trial = simulateIsland(current, ctx); if ( @@ -1327,15 +1726,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; @@ -1415,9 +1814,12 @@ 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; - current[tile] = building; + put(current, ctx, tile, building); const trial = simulateIsland(current, ctx); if ( trial.totalPower - bestPower > bestGain && @@ -1427,12 +1829,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; } @@ -1496,7 +1898,7 @@ function pruneDeadWeight( bestPower = trial.totalPower; rows = trial.placements; } else { - current[tile] = held; + put(current, ctx, tile, held); } } @@ -1546,6 +1948,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 x4 load against a x1 capacity: an + * isolated generator authored 320, rated 1280 and absorbing 300, was never + * offered the authored 120 tier that covers it at its own rating of 480, so the + * pass left 980 of intake nobody pays it to have — which is exactly the money it + * exists to hand back. In the other direction the plain figure is over-generous, + * and there the re-simulation below catches it, so only the waste escaped. + * + * The neighbour scan mirrors `simulateIsland`'s: it reads what a neighbour *is*, + * which the swap cannot change — the ladder is one role deep, so a re-tiered + * generator is still a generator and every tile's crowding survives the pass. + */ +function ratedCapacity( + ctx: IslandContext, + placement: Placement, + tile: number, + candidate: EffectiveBuilding, +): number { + const isolation = ctx.isolation; + if (isolation === null || candidate.type !== isolation.role) { + return candidate.effectiveValue; + } + + const neighbors = ctx.neighbors[tile]; + let crowded = false; + for (let k = 0; k < neighbors.length; k++) { + const other = placement[neighbors[k]]; + if (other !== null && other.type === isolation.role) { + crowded = true; + break; + } + } + return ctx.rateIsolated(candidate, crowded).effectiveValue; +} + /** Each downgradable role's roster entries, smallest capacity first. */ function downgradeTiers( effectiveBuildings: EffectiveBuilding[], @@ -1607,7 +2050,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); @@ -1627,11 +2070,23 @@ export function downgradeOversized( const load = tileLoad(building, rowAt.get(tile)!); for (const candidate of ladder) { - // Ascending, so nothing smaller is left to try. - if (candidate.effectiveValue >= building.effectiveValue) break; - if (!covers(candidate.effectiveValue, load)) continue; - - current[tile] = candidate; + // Rated for this tile before it is compared to anything: `building` is + // what the tile is running and `load` is what the layout measured it + // doing, both in this tile's units, and the ladder is the plain roster. + // Ascending order survives the rating, since one tile scales every + // candidate by the same factor. + const rated = ctx.rate(tile, candidate); + // Ascending, so nothing smaller is left to try. Both sides are `rate`'s + // units, and one tile scales every candidate by the same factor, so the + // ladder's order survives whichever units this is asked in. + if (rated.effectiveValue >= building.effectiveValue) break; + // Covering the load is asked in the layout's units, which is `rate`'s + // plus whatever `simulateIsland` resolves per layout — see + // `ratedCapacity`. `load` came off a row it measured, so this is the one + // comparison here that cannot be made in the roster's units. + if (!covers(ratedCapacity(ctx, current, tile, rated), load)) continue; + + put(current, ctx, tile, rated); const trial = simulateIsland(current, ctx); if ( @@ -1646,7 +2101,7 @@ export function downgradeOversized( break; } - current[tile] = building; + put(current, ctx, tile, building); } } } @@ -1751,7 +2206,14 @@ 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, + undefined, + island.waterAdjacent, + ); if (ctx.n === 0) return { placements: [], powerOutput: 0.0 }; const reactors = effectiveBuildings @@ -1812,21 +2274,37 @@ 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, + island.waterAdjacent, + ); 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; @@ -1889,16 +2367,27 @@ 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 generatorCapacity = generatorCapacityTable(island, ctx, generators[0]); + const ratingCeiling = islandRatingCeiling( + ctx, + [reactors[0], generators[0], coolers[0]].filter((b) => b !== undefined), + generatorCapacity !== null, + ); for (const target of targetCompositions( ctx.n, reactors, generators, coolers, + generatorCapacity, )) { if ( performance.now() >= compositionDeadlineMs || pacer.stopRequested || - target.power <= bestPower + EPS + !targetCanBeat(target, bestPower, ratingCeiling) ) { break; } @@ -2038,7 +2527,12 @@ export const internals = { DOWNGRADE_ROLES, constructMultiStartSeed, downgradeTiers, + greedyPolish, hillClimb, + generatorCapacityTable, + islandRatingCeiling, + isUnderFed, + targetCanBeat, offlineProducers, pruneDeadWeight, targetCompositions, diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts index 71b35fa..3c4b362 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 — 66.7% under a + * maxed Stellar Forge, 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 { @@ -91,6 +111,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 +174,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 +196,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 +205,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; } @@ -160,17 +230,66 @@ 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] = placement[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; @@ -179,13 +298,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 +316,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,14 +326,18 @@ 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); } 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, @@ -245,7 +368,14 @@ 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, + // ...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/solver.ts b/packages/solver/src/solver/solver.ts index 482b880..04498cf 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,21 +72,38 @@ 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; + // 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; - 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, + anomaly, budgetsS: grassCounts.map( (count) => timeBudgetS * (count / totalIslandGrass), ), @@ -86,7 +114,11 @@ export function planSolve( ), originalWidth: grid[0].length, totalGrassTiles, - theoreticalMaxPower: estimateTotalMaxPower(islands, effectiveBuildings), + theoreticalMaxPower: estimateTotalMaxPower( + islands, + effectiveBuildings, + anomaly, + ), }; } @@ -191,6 +223,8 @@ export async function solve( unlockedUpgrades, timeBudgetS, rngSeed, + options?.anomalyId, + options?.prestige, ); if (!plan) return createEmptyResult(grid?.[0]?.length ? countGrassTiles(grid) : 0); @@ -214,6 +248,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..388df1c 100755 --- a/packages/solver/src/solver/types.ts +++ b/packages/solver/src/solver/types.ts @@ -126,12 +126,230 @@ 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. + */ +/** + * 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: PrestigeUpgradeId; + 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 board pays into one pool that reaches every + * producer regardless of adjacency, and each is re-rated on the way in. + * + * 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"; + /** What every cooler's cooling is multiplied by while this is running. */ + coolerMultiplier: 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; + /** + * 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; /** * 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 +416,36 @@ export interface SolveOptions { onProgress?: (result: OptimizationResult) => void; shouldStop?: () => boolean; reportIntervalMs?: number; + /** + * The timeline's anomaly, by id. Omitted means the base rules. + * + * 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 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; } // ============================================================================ @@ -212,6 +460,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..12f924e --- /dev/null +++ b/packages/solver/tests/anomalies.test.ts @@ -0,0 +1,1106 @@ +/** + * 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 { 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, type IslandContext } from "../src/solver/context"; +import { EPS } from "../src/solver/constants"; +import { simulateIsland } from "../src/solver/simulate"; +import { makeGrid } from "../src/grid"; +import { + canCoolDirectProducer, + estimateTotalMaxPower, + splitGridIntoIslands, +} from "../src/solver/island"; +import { internals, solveIsland } from "../src/solver/placementSearch"; +import { basicCatalogue, basicRoster, cooler, reactor } 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: 100, + energy: 70, + waste: 30, + baseValue: 100, +}; + +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", () => { + /* + * `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("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); + + 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", () => { + // 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, + ); + }); +}); + +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("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 + * 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 x4 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 * 4, 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")! * 4, 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 + * 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", () => { + // 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("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. + * 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 + * 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); + }); +}); + +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 * 4, + 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(400, 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 x2 on the generator and Absolute Zero at x2 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 x2", + ).toEqual([100, 200, 200]); + + 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 at level 2 and then a + * Tidal shore is 1.8474375e22, against 1.8474374999999997e22 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: 1 }), + ); + expect(researched.effectiveValue).toBe(8.85e21 * 1.25); + 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 * 1.25 * 1.67); + expect(onShore.effectiveValue).toBe(1.8474375e22); + // The anomaly first and the research second, which is the wrong way round. + expect(onShore.effectiveValue).not.toBe(8.85e21 * 1.67 * 1.25); + expect(onShore.baseValue).toBe(8.85e21); + }); +}); diff --git a/packages/solver/tests/blueprint.test.ts b/packages/solver/tests/blueprint.test.ts index 75c4880..8501683 100755 --- a/packages/solver/tests/blueprint.test.ts +++ b/packages/solver/tests/blueprint.test.ts @@ -20,10 +20,15 @@ */ 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, + MIN_GRID_DIM, blueprintKey, + blueprintRules, decodeBlueprint, encodeBlueprint, type BlueprintPlacement, @@ -224,18 +229,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 +311,250 @@ 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, [])); + }); + + /* + * 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", () => { + /* + * 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..35faabe 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -15,10 +15,20 @@ 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 { 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 { canCoolDirectProducer, + computeWaterAdjacency, countGrassTiles, estimateTotalMaxPower, + minIslandTiles, splitGridIntoIslands, } from "../src/solver/island"; import { Rng } from "../src/solver/rng"; @@ -27,6 +37,8 @@ import type { EffectiveBuilding, IslandSubGrid, Placement, + RoleIsolationAnomaly, + TerrainAffinityAnomaly, } from "../src/solver/types"; import { basicRoster, @@ -34,6 +46,7 @@ import { directProducer, expectClose, generator, + placeOn, reactor, } from "./helpers"; @@ -161,6 +174,217 @@ 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("does not apply under a shared cooling pool — the board is one island", () => { + /* + * 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, + ]); + + 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]); + }); +}); + +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 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], + [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); + }); + + 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", () => { it("points original tile indices back at the source tiles", () => { const grid = makeGrid(["......", "..GGG.", "..GGG."]); @@ -192,7 +416,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 +470,9 @@ describe("the theoretical max-power bound", () => { width: 0, height: 0, grid: [], + buildable: new Uint8Array(0), + waterAdjacent: new Uint8Array(0), + tileCount: 0, originalTileIndices: [], }; @@ -293,6 +529,664 @@ 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 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 }); + 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("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 + * 4, so wherever generator intake is the short side of + * `min(nReact * rVal, nGen * gVal)` a layout that keeps its generators apart + * absorbs up to 4x 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 4) of intake against 400 of reactor heat, at 0.75 + // energy, with 80 of waste against 100 of cooling. + expectClose(totalPower, 240); + // 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, + ); + }); + + describe("the neighbourhood cap on a generator's intake", () => { + /* + * The other half of allowing the isolated rating, and the half that costs + * something. Heat crosses a tile boundary and nothing else, so a + * generator's intake is the output of the reactors *beside* it, and the + * all-or-nothing cooling rule wants coolers beside it too — out of the same + * eight tiles. That ceiling is the one piece of adjacency the bound does + * not relax away, because it is the one that binds. + * + * It is homogeneous in the roster, so no rule that scales everything at + * once can make it bite. A rule that scales a single role is exactly what + * it takes, and `role_isolation` is that rule: rating a lone generator x4 + * while leaving the reactors that fill it alone let the LP spend fewer + * tiles on generators than eight neighbours apiece can serve, and the + * shipped maps' bound sat ~5% above anything the board allows. + * + * Ten tiles by four so the split settles: on a small island the integer + * `nReact` lands short of eight reactors per generator by itself, and the + * cap has nothing to say. + */ + const singularity = getAnomaly( + "singularity_isolation", + ) as RoleIsolationAnomaly; + const board = ["GGGGGGGGGG", "GGGGGGGGGG", "GGGGGGGGGG", "GGGGGGGGGG"]; + + /** Eight neighbours of 100-heat reactors, less what the coolers among them cost. */ + const cap = 8 / (1 / 100 + 0.25 / 100); + + it("stops the bound rising with a generator eight reactors cannot fill", () => { + /* + * Two rosters four tiers apart in generator, both rated past the cap: + * the bound has to answer the same number for each, because what a + * generator tile is worth is settled by its neighbours and not by its + * tier once the tier outruns them. + */ + const small = basicRoster({ generatorValue: 400 }); + const large = basicRoster({ generatorValue: 4000 }); + const island = islandFor(board, small); + + expect(400 * singularity.isolated).toBeGreaterThan(cap); + expectClose( + estimateTotalMaxPower([island], small, singularity), + estimateTotalMaxPower([island], large, singularity), + ); + }); + + it("is the same ceiling whatever rated a generator past it", () => { + /* + * A generator authored at 4000 and one rated there by the anomaly are + * the same building to the cap, so the plain bound on the first is the + * anomaly's bound on the second. Which is also why the anomaly buys so + * much less than its x4 suggests: past eight reactors it buys nothing. + */ + const island = islandFor(board, basicRoster()); + + expectClose( + estimateTotalMaxPower( + [island], + basicRoster({ generatorValue: 400 }), + singularity, + ), + boundFor(island, basicRoster({ generatorValue: 4000 })), + ); + }); + + it("leaves a generator its neighbours can fill alone", () => { + // Under the cap the tier still decides, so the bound still rises with it + // — the ceiling is a ceiling, not a clamp on everything. + const island = islandFor(board, basicRoster()); + + expect( + boundFor(island, basicRoster({ generatorValue: 100 })), + ).toBeLessThan(boundFor(island, basicRoster({ generatorValue: 400 }))); + }); + + it("is not beaten by a layout built to the cap's own shape", () => { + /* + * The arrangement the cap describes, built by hand: a generator alone in + * the middle of its own 3x3 — so it rates `isolated` — with its eight + * neighbours split between the reactors that fill it and the coolers + * that keep it online. Six reactors is the most this roster can cool + * (6 x 25 of waste against 2 x 100 of cooling), and 600 of intake is + * what the cap allows for. + */ + const roster = basicRoster({ generatorValue: 400 }); + const island = islandFor(["GGG", "GGG", "GGG"], roster); + const ctx = buildIslandContext( + island.grid, + island.buildable, + singularity, + island.waterAdjacent, + ); + const placement = placeOn(ctx, ["RRR", "RGR", "CCR"], { + R: roster[0], + G: roster[1], + C: roster[2], + }); + + const { totalPower } = simulateIsland(placement, ctx); + + // 600 of intake at 0.75, against a 640 ceiling the tier alone would have + // put at 1000. + expectClose(totalPower, 450); + expect(600).toBeLessThan(cap); + expect(totalPower).toBeLessThanOrEqual( + estimateTotalMaxPower([island], roster, singularity) + EPS, + ); + }); + + it.each([ + ["under role isolation", 400, singularity], + // The cap applies under the base rules too, and a partial roster is + // where it bites there: a strong generator behind a weak reactor is an + // ordinary way to be part-way through the catalogue. Nothing rates this + // one — 4000 of intake simply outruns what eight 100-heat reactors can + // deliver, which is the same ceiling by a different route. + ["on a plain roster the cap reaches", 4000, undefined], + ])( + "is never beaten by a random layout %s", + (_name, generatorValue, rules) => { + const roster = basicRoster({ generatorValue, dpValue: 120 }); + const island = islandFor(["GGGG", "GGGG", "GGGG", "GGGG"], roster); + const bound = estimateTotalMaxPower([island], roster, rules); + const ctx = buildIslandContext( + island.grid, + island.buildable, + rules, + island.waterAdjacent, + ); + + const rng = new Rng(20260922); + const options: (EffectiveBuilding | null)[] = [...roster, null]; + for (let attempt = 0; attempt < 400; attempt++) { + const placement: Placement = Array.from({ length: ctx.n }, () => + rng.choice(options), + ); + + const { totalPower } = simulateIsland(placement, ctx); + + expect( + totalPower, + `a layout beat the 'upper' bound on attempt ${attempt}`, + ).toBeLessThanOrEqual(bound + EPS); + } + }, + ); + }); + + describe("the room an island has to keep generators apart", () => { + /* + * The other half of the same argument, and the half a *generator-bound* + * roster runs into. `roleRatedRoster` rates every generator at the bonus, + * which a search free to spread them out can reach — until the roster is + * one where the generators are the short side. Then the split wants a + * third of the island to be generators, every one of them isolated, and + * isolated generators are pairwise non-adjacent by definition: an + * independent set in the 8-neighbour graph, which no board can make a + * third of itself. + * + * `isolationRoom` reads the ceiling off a 2x2 block partition — four + * mutually adjacent tiles, so one isolated generator between them, and no + * other generator at all in that block. Gale Hills at generator7 tier 1 + * asked for 19.3 isolated generators where the island admits 15, and read + * 78% layout efficiency for the best layout any run has found. + */ + const singularity = getAnomaly( + "singularity_isolation", + ) as RoleIsolationAnomaly; + + /** The roster the bound used to run on: every generator at the bonus. */ + function allIsolated(roster: EffectiveBuilding[]): EffectiveBuilding[] { + return roster.map((b) => + b.type === "generator" + ? scaleEffectiveBuilding(b, singularity.isolated) + : b, + ); + } + + it("comes in under rating every generator at the bonus", () => { + /* + * Generators are the short side here — one isolated generator takes + * half a reactor's heat — so the split wants more of them than a 4x4 + * has room to keep apart, and the bound has to say so. + */ + const roster = basicRoster({ generatorValue: 40 }); + const island = islandFor(["GGGG", "GGGG", "GGGG", "GGGG"], roster); + + expect(estimateTotalMaxPower([island], roster, singularity)).toBeLessThan( + boundFor(island, allIsolated(roster)), + ); + }); + + it("leaves a roster with room to spare exactly where it was", () => { + // A generator worth four reactors needs few tiles, and few is well + // inside what a board can keep apart — so the ceiling is not reached + // and the figure is the one it always was, bit for bit. + const roster = basicRoster({ generatorValue: 400 }); + const island = islandFor(["GGGG", "GGGG", "GGGG", "GGGG"], roster); + + expect(estimateTotalMaxPower([island], roster, singularity)).toBe( + boundFor(island, allIsolated(roster)), + ); + }); + + it("counts the room a shape has, not the tiles it has", () => { + /* + * Two islands of eight tiles, and the bound reads them 50% apart. A 2x4 + * block is four 2x2 blocks of two tiles each, and every tile in it is + * next to six others; a row of eight is four blocks too, but a + * generator on it only ever has two neighbours, so the row can keep + * three apart and still have tiles left for what feeds them. Tile count + * is what the rest of the LP runs on and it cannot tell these apart. + */ + const roster = basicRoster({ generatorValue: 40 }); + const block = islandFor(["GGGG", "GGGG"], roster); + const row = islandFor(["GGGGGGGG"], roster); + + expect(block.tileCount).toBe(row.tileCount); + expect(estimateTotalMaxPower([row], roster, singularity)).toBeGreaterThan( + estimateTotalMaxPower([block], roster, singularity), + ); + }); + + it("is not beaten by any layout a small board can hold", () => { + /* + * Exhaustive rather than sampled, which is what this rule needs: a + * random layout almost never isolates several generators at once, and + * an isolated one is worth 3.1x a crowded one. Every arrangement of + * four options over nine tiles is 262144 boards, and the bound has to + * stand over all of them. + */ + const roster = basicRoster({ generatorValue: 40 }); + const island = islandFor(["GGG", "GGG", "GGG"], roster); + const bound = estimateTotalMaxPower([island], roster, singularity); + const ctx = buildIslandContext( + island.grid, + island.buildable, + singularity, + island.waterAdjacent, + ); + + const options: (EffectiveBuilding | null)[] = [...roster, null]; + const total = options.length ** ctx.n; + // The best layout is tracked and asserted once rather than per board: + // 262144 assertions cost seconds where the simulations cost + // milliseconds, and one is all the property needs. + let reached = 0; + let reachedBy = -1; + for (let code = 0; code < total; code++) { + let rest = code; + const placement: Placement = new Array(ctx.n); + for (let t = 0; t < ctx.n; t++) { + placement[t] = options[rest % options.length]; + rest = (rest - (rest % options.length)) / options.length; + } + + const { totalPower } = simulateIsland(placement, ctx); + if (totalPower > reached) { + reached = totalPower; + reachedBy = code; + } + } + + expect( + reached, + `layout ${reachedBy} beat the 'upper' bound`, + ).toBeLessThanOrEqual(bound + EPS); + // And it is a bound worth having: something on this board reaches it. + expect(reached / bound).toBeGreaterThan(0.9); + }); + }); + + 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 + * 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. + */ + 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.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]; + 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(); + // 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( + ["RRRRR", "RGGGR", "RGGGR", "RGGGR", "RRRRR"], + roster, + ); + + expectClose( + estimateTotalMaxPower([shore], roster, tidal), + estimateTotalMaxPower([shore], roster) * 1.67, + ); + expectClose( + estimateTotalMaxPower([inland], roster, tidal), + estimateTotalMaxPower([inland], roster), + ); + }); + + 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 + * 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.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.rating.test.ts b/packages/solver/tests/placementSearch.rating.test.ts new file mode 100644 index 0000000..c1a2a78 --- /dev/null +++ b/packages/solver/tests/placementSearch.rating.test.ts @@ -0,0 +1,529 @@ +/** + * 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 { + canCoolDirectProducer, + splitGridIntoIslands, +} from "../src/solver/island"; +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 maxed Stellar + * Forge (x1.5) the same reactor has to fall below 66.7% 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("what to build, counted in the layout's units", () => { + /* + * The same mistake as the rest of this file, made by the one stage that + * decides *what* to build rather than where to put it. `targetCompositions` + * counts, and counting needs to know what a generator is worth — which under + * `role_isolation` is two numbers, x4 with no generator beside it and x0.8 + * with. Which a tile gets is the layout's business, so neither is in the pool + * the stage draws on, and the count it produced was the one for a roster + * nobody is playing. + * + * Sizing at the bonus alone is not the fix either: it asks Gale Hills at + * generator7 tier 1 for 20 generators on an island that can keep 15 apart, + * and no arrangement holds that many clear of each other. The count has a + * ceiling the roster cannot see and the island can — `isolationRoom` — so the + * table is the one `estimateTotalMaxPower` runs on, imported rather than + * restated. With it the mean of ten 15s runs on that island goes from 44.6AC + * to 46.2AC, where without it the search plateaued at 43.2-45.2AC from 5s to + * 150s. + */ + const { generatorCapacityTable, targetCompositions } = internals; + const roster = [reactor(100), generator(40), cooler(100)]; + const [R, G, C] = roster; + /** Four 2x2 blocks, so four generators may hold the bonus at once. */ + const board = ["GGGG", "GGGG", "GGGG", "GGGG"]; + + function islandAndContext(anomaly: ReturnType) { + const island = splitGridIntoIslands( + makeGrid(board), + canCoolDirectProducer(roster), + anomaly, + )[0]; + return { + island, + ctx: buildIslandContext( + island.grid, + island.buildable, + anomaly, + island.waterAdjacent, + ), + }; + } + + it("counts linearly under every rule that does not rate by isolation", () => { + // No table at all, so the stage runs the multiply it always ran: a rule + // that rates a tile or a whole role leaves the *count* alone. + for (const id of ["none", "cryo_nexus", "tidal_ascendancy"]) { + const { island, ctx } = islandAndContext(getAnomaly(id)); + expect(generatorCapacityTable(island, ctx, G), id).toBeNull(); + } + }); + + it("bends where the island runs out of room for the bonus", () => { + const { island, ctx } = islandAndContext(singularity); + const table = generatorCapacityTable(island, ctx, G)!; + + // Four generators on a 4x4 can all stand clear of each other... + expect(table[4]).toBe(4 * 40 * 4); + // ...and a fifth cannot be put anywhere that is not beside one of them, so + // it costs one of the four its bonus rather than adding to them. + expect(table[5]).toBeLessThan(table[4]); + }); + + it("asks for fewer generators, and for more than they used to be worth", () => { + /* + * The stage's answer on this board: 4 generators where the plain count + * says 10, and a target worth 480 against 300 — all four isolated, one per + * 2x2 block, which is a layout this island can hold, where ten at the bonus + * is not. + */ + const { island, ctx } = islandAndContext(singularity); + const table = generatorCapacityTable(island, ctx, G); + + const [rated] = targetCompositions(ctx.n, [R], [G], [C], table); + const [plain] = targetCompositions(ctx.n, [R], [G], [C], null); + const generators = (target: typeof rated) => + target.composition.filter((b) => b.type === "generator").length; + + expect(generators(rated)).toBeLessThan(generators(plain)); + expect(rated.power).toBeGreaterThan(plain.power); + }); +}); + +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, false), 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, + false, + ), + ).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, + false, + ), + ).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, false)).toBeCloseTo(4, 9); + }); + + it("drops the isolation half once the target carries it", () => { + /* + * The ceiling exists to put a target scored from the plain roster into the + * units of a rated layout. A target sized through `generatorCapacities` + * already carries the isolated rating — and the room the island has for it + * — so counting it again here would scale a rated figure by the rating a + * second time, and the gate would wave through targets that cannot come + * close. + */ + const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, singularity); + expect(islandRatingCeiling(ctx, probes, true)).toBe(1); + }); + + 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], null)[0]; + const ceiling = islandRatingCeiling(ctx, roster, false); + + 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 four + * times 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 x4, so the authored 320 tile is running at 1280 + * and absorbing 300 of it. The smallest tier that covers that load is the + * authored 120 — rated 480 — and at the same power, because a generator's + * energy scales with how full it is: 0.75 x 1280 x 300/1280 is 0.75 x 480 x + * 300/480. Compared in the roster's units instead, 120 does not cover 300 and + * the pass leaves 980 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 480 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(1280, 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 23% 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/placementSearch.test.ts b/packages/solver/tests/placementSearch.test.ts index 2fa06b1..37d0ff2 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, @@ -498,7 +498,7 @@ describe("target compositions", () => { cooler(25), ]); - const targets = targetCompositions(3, reactors, generators, coolers); + const targets = targetCompositions(3, reactors, generators, coolers, null); expect(targets.length).toBeGreaterThan(0); expect(countById(targets[0].composition)).toEqual({ @@ -520,7 +520,7 @@ describe("target compositions", () => { cooler(5), ]); - const [best] = targetCompositions(10, reactors, generators, coolers); + const [best] = targetCompositions(10, reactors, generators, coolers, null); const counts = countById(best.composition); expect(best.composition.length, "every tile is allocated").toBe(10); @@ -554,7 +554,7 @@ describe("target compositions", () => { cooler(2.35e21, "cooler7"), ]); - const [best] = targetCompositions(7, reactors, generators, coolers); + const [best] = targetCompositions(7, reactors, generators, coolers, null); expect( best.power, @@ -576,6 +576,7 @@ describe("target compositions", () => { reactors, generators, coolers, + null, )) { expect(composition.length).toBe(tileCount); @@ -613,9 +614,13 @@ describe("target compositions", () => { cooler(25), ]); - const ceilings = targetCompositions(12, reactors, generators, coolers).map( - (c) => c.power, - ); + const ceilings = targetCompositions( + 12, + reactors, + generators, + coolers, + null, + ).map((c) => c.power); expect(ceilings).toEqual([...ceilings].sort((a, b) => b - a)); }); @@ -627,8 +632,10 @@ describe("target compositions", () => { cooler(25), ]); - expect(targetCompositions(2, reactors, generators, coolers)).toEqual([]); - expect(targetCompositions(3, [], generators, coolers)).toEqual([]); + expect(targetCompositions(2, reactors, generators, coolers, null)).toEqual( + [], + ); + expect(targetCompositions(3, [], generators, coolers, null)).toEqual([]); }); it("is never beaten by a real layout", async () => { @@ -641,8 +648,13 @@ describe("target compositions", () => { const { reactors, generators, coolers } = sortedPools(roster); const tiles = buildIslandContext(island.grid).n; - const ceiling = targetCompositions(tiles, reactors, generators, coolers)[0] - .power; + const ceiling = targetCompositions( + tiles, + reactors, + generators, + coolers, + null, + )[0].power; const { powerOutput } = await solveIsland(island, roster, 0.5); expect( diff --git a/packages/solver/tests/prestige.test.ts b/packages/solver/tests/prestige.test.ts new file mode 100644 index 0000000..e7974cd --- /dev/null +++ b/packages/solver/tests/prestige.test.ts @@ -0,0 +1,251 @@ +/** + * 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 * 1.1); + 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([ + 400 * 1.1, + 300 * 1.1, + 110, // derived as snap(heat - energy), not the authored waste scaled + ]); + }); + + 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 * 1.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(100 * 1.1); + }); + + 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, x1.1 + const roster = getEffectiveBuildings( + DEFS as BuildingDefinition[], + ALL, + scales, + ); + const cooler = roster.find((b) => b.id === "c")!; + + expect(cooler.effectiveValue).toBe(100 * 1.1); // 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, x1.1 + 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/scaling.test.ts b/packages/solver/tests/scaling.test.ts new file mode 100644 index 0000000..7fc9acb --- /dev/null +++ b/packages/solver/tests/scaling.test.ts @@ -0,0 +1,198 @@ +/** + * `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 at level 4 + // (x1.7) and a crowded generator under Singularity Isolation (x0.8) over + // generator7's fourth tier give 1.2036e22 one way and 1.2036000000000001e22 + // the other. + const researched = scaleEffectiveBuilding(atTier(3), 1.7); + const both = scaleEffectiveBuilding(researched, 0.8); + + expect(both.effectiveValue).toBe(8.85e21 * 1.7 * 0.8); + expect(both.effectiveValue).toBe(1.2036e22); + expect(both.effectiveValue).not.toBe(8.85e21 * (1.7 * 0.8)); + }); + + 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: 1.7 x 0.8 and 0.8 x 1.7 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 at level 2 (x1.25) then a Tidal shore (x1.67) the two orders + // differ in the last bit: 1.8474375e22 against 1.8474374999999997e22. + // + // 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), 1.25), + 1.67, + ); + const anomalyFirst = scaleEffectiveBuilding( + scaleEffectiveBuilding(atTier(3), 1.67), + 1.25, + ); + + expect(researchFirst.effectiveValue).toBe(8.85e21 * 1.25 * 1.67); + expect(researchFirst.effectiveValue).toBe(1.8474375e22); + expect(anomalyFirst.effectiveValue).toBe(1.8474374999999997e22); + 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 third tier (4.43e21 / + * 3.32e21) under Infinite Grid at level 1 (x1.1) and then a x0.8 penalty + * gives 9.76800000000001e20 derived against 9.768e20 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(2), 1.1); + const both = scaleEffectiveBuilding(once, 0.8); + + expect(both.waste).toBe(9.76800000000001e20); + 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 9.768e20. + expect(both.waste).not.toBe(1.11e21 * 1.1 * 0.8); + }); +}); + +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); + }); +}); 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/data/web_atlas.webp b/public/data/web_atlas.webp index 60dc4c4..247c19d 100644 Binary files a/public/data/web_atlas.webp and b/public/data/web_atlas.webp differ diff --git a/public/icons/anomaly_cryo_nexus.webp b/public/icons/anomaly_cryo_nexus.webp new file mode 100644 index 0000000..b63d1cc Binary files /dev/null and b/public/icons/anomaly_cryo_nexus.webp differ diff --git a/public/icons/anomaly_singularity_isolation.webp b/public/icons/anomaly_singularity_isolation.webp new file mode 100644 index 0000000..fc37bc4 Binary files /dev/null and b/public/icons/anomaly_singularity_isolation.webp differ diff --git a/public/icons/anomaly_tidal_ascendancy.webp b/public/icons/anomaly_tidal_ascendancy.webp new file mode 100644 index 0000000..998c75b Binary files /dev/null and b/public/icons/anomaly_tidal_ascendancy.webp differ diff --git a/public/icons/antimatter_plant.webp b/public/icons/antimatter_plant.webp index e87b99d..369497b 100644 Binary files a/public/icons/antimatter_plant.webp and b/public/icons/antimatter_plant.webp differ diff --git a/public/icons/biomass_plant.webp b/public/icons/biomass_plant.webp index 9d92887..9474ddd 100644 Binary files a/public/icons/biomass_plant.webp and b/public/icons/biomass_plant.webp differ diff --git a/public/icons/black_hole_reactor.webp b/public/icons/black_hole_reactor.webp index 3feb5bb..cfbfc87 100644 Binary files a/public/icons/black_hole_reactor.webp and b/public/icons/black_hole_reactor.webp differ diff --git a/public/icons/chaos_core.webp b/public/icons/chaos_core.webp index c3d5929..b1911e7 100644 Binary files a/public/icons/chaos_core.webp and b/public/icons/chaos_core.webp differ diff --git a/public/icons/coal_plant.webp b/public/icons/coal_plant.webp index 1e8043e..45c65d3 100644 Binary files a/public/icons/coal_plant.webp and b/public/icons/coal_plant.webp differ diff --git a/public/icons/cooler1.webp b/public/icons/cooler1.webp index 2e8ffcc..89539be 100644 Binary files a/public/icons/cooler1.webp and b/public/icons/cooler1.webp differ diff --git a/public/icons/cooler2.webp b/public/icons/cooler2.webp index c3d5432..e4cf06b 100644 Binary files a/public/icons/cooler2.webp and b/public/icons/cooler2.webp differ diff --git a/public/icons/cooler4.webp b/public/icons/cooler4.webp index 7e22d55..de7b870 100644 Binary files a/public/icons/cooler4.webp and b/public/icons/cooler4.webp differ diff --git a/public/icons/cooler5.webp b/public/icons/cooler5.webp index 4d7d6aa..b9aaf3b 100644 Binary files a/public/icons/cooler5.webp and b/public/icons/cooler5.webp differ diff --git a/public/icons/cooler6.webp b/public/icons/cooler6.webp index dbcba5b..df653a4 100644 Binary files a/public/icons/cooler6.webp and b/public/icons/cooler6.webp differ diff --git a/public/icons/cooler7.webp b/public/icons/cooler7.webp index c339a7c..2f5dfc8 100644 Binary files a/public/icons/cooler7.webp and b/public/icons/cooler7.webp differ diff --git a/public/icons/divine_reactor.webp b/public/icons/divine_reactor.webp index fb43f33..9d1544a 100644 Binary files a/public/icons/divine_reactor.webp and b/public/icons/divine_reactor.webp differ diff --git a/public/icons/doomStar_reactor.webp b/public/icons/doomStar_reactor.webp index ab2f233..a3511db 100644 Binary files a/public/icons/doomStar_reactor.webp and b/public/icons/doomStar_reactor.webp differ diff --git a/public/icons/flux_reactor.webp b/public/icons/flux_reactor.webp index 0fd086a..aa96973 100644 Binary files a/public/icons/flux_reactor.webp and b/public/icons/flux_reactor.webp differ diff --git a/public/icons/fusion_reactor.webp b/public/icons/fusion_reactor.webp index 8a1f76e..1d92530 100644 Binary files a/public/icons/fusion_reactor.webp and b/public/icons/fusion_reactor.webp differ diff --git a/public/icons/gas_plant.webp b/public/icons/gas_plant.webp index 813c7ab..9b9730c 100644 Binary files a/public/icons/gas_plant.webp and b/public/icons/gas_plant.webp differ diff --git a/public/icons/generator.webp b/public/icons/generator.webp index 207dec9..64f987b 100644 Binary files a/public/icons/generator.webp and b/public/icons/generator.webp differ diff --git a/public/icons/generator2.webp b/public/icons/generator2.webp index 0204bbd..a933013 100644 Binary files a/public/icons/generator2.webp and b/public/icons/generator2.webp differ diff --git a/public/icons/generator3.webp b/public/icons/generator3.webp index 09f2cea..0e16c38 100644 Binary files a/public/icons/generator3.webp and b/public/icons/generator3.webp differ diff --git a/public/icons/generator4.webp b/public/icons/generator4.webp index 49bc2dd..2e6e1be 100644 Binary files a/public/icons/generator4.webp and b/public/icons/generator4.webp differ diff --git a/public/icons/generator5.webp b/public/icons/generator5.webp index 0745aa1..d65d015 100644 Binary files a/public/icons/generator5.webp and b/public/icons/generator5.webp differ diff --git a/public/icons/generator6.webp b/public/icons/generator6.webp index dec7e15..ff10b80 100644 Binary files a/public/icons/generator6.webp and b/public/icons/generator6.webp differ diff --git a/public/icons/generator7.webp b/public/icons/generator7.webp index cc29b1d..ee7aa1f 100644 Binary files a/public/icons/generator7.webp and b/public/icons/generator7.webp differ diff --git a/public/icons/geothermal_plant.webp b/public/icons/geothermal_plant.webp index 7ab56b9..b3a0019 100644 Binary files a/public/icons/geothermal_plant.webp and b/public/icons/geothermal_plant.webp differ diff --git a/public/icons/gravitron_plant.webp b/public/icons/gravitron_plant.webp index d812638..1876eb9 100644 Binary files a/public/icons/gravitron_plant.webp and b/public/icons/gravitron_plant.webp differ diff --git a/public/icons/heliothermal_plant.webp b/public/icons/heliothermal_plant.webp index a4d8911..3acc87d 100644 Binary files a/public/icons/heliothermal_plant.webp and b/public/icons/heliothermal_plant.webp differ diff --git a/public/icons/hydro_plant.webp b/public/icons/hydro_plant.webp index fbd94e3..3a2fd7e 100644 Binary files a/public/icons/hydro_plant.webp and b/public/icons/hydro_plant.webp differ diff --git a/public/icons/hyper_space_core.webp b/public/icons/hyper_space_core.webp index 4e968d5..1bf0f56 100644 Binary files a/public/icons/hyper_space_core.webp and b/public/icons/hyper_space_core.webp differ diff --git a/public/icons/icon_cooling.webp b/public/icons/icon_cooling.webp index 2832ded..e561779 100644 Binary files a/public/icons/icon_cooling.webp and b/public/icons/icon_cooling.webp differ diff --git a/public/icons/icon_energy.webp b/public/icons/icon_energy.webp index 77a53b3..0eea4cf 100644 Binary files a/public/icons/icon_energy.webp and b/public/icons/icon_energy.webp differ diff --git a/public/icons/icon_heat.webp b/public/icons/icon_heat.webp index 7d0443e..59c6700 100644 Binary files a/public/icons/icon_heat.webp and b/public/icons/icon_heat.webp differ diff --git a/public/icons/neuro_grid_reactor.webp b/public/icons/neuro_grid_reactor.webp index 773af8d..2ab25ae 100644 Binary files a/public/icons/neuro_grid_reactor.webp and b/public/icons/neuro_grid_reactor.webp differ diff --git a/public/icons/nuclear_reactor.webp b/public/icons/nuclear_reactor.webp index 44d437b..22a013d 100644 Binary files a/public/icons/nuclear_reactor.webp and b/public/icons/nuclear_reactor.webp differ diff --git a/public/icons/prestige_absolute_zero.webp b/public/icons/prestige_absolute_zero.webp new file mode 100644 index 0000000..d359f3b Binary files /dev/null and b/public/icons/prestige_absolute_zero.webp differ diff --git a/public/icons/prestige_infinite_grid.webp b/public/icons/prestige_infinite_grid.webp new file mode 100644 index 0000000..64eaca5 Binary files /dev/null and b/public/icons/prestige_infinite_grid.webp differ diff --git a/public/icons/prestige_stellar_forge.webp b/public/icons/prestige_stellar_forge.webp new file mode 100644 index 0000000..f17c701 Binary files /dev/null and b/public/icons/prestige_stellar_forge.webp differ diff --git a/public/icons/quantum_reactor.webp b/public/icons/quantum_reactor.webp index 9ad9c9e..6790456 100644 Binary files a/public/icons/quantum_reactor.webp and b/public/icons/quantum_reactor.webp differ diff --git a/public/icons/sauron_eye.webp b/public/icons/sauron_eye.webp index 85a3951..e320008 100644 Binary files a/public/icons/sauron_eye.webp and b/public/icons/sauron_eye.webp differ diff --git a/public/icons/solar_panel.webp b/public/icons/solar_panel.webp index f09e907..cad7597 100644 Binary files a/public/icons/solar_panel.webp and b/public/icons/solar_panel.webp differ diff --git a/public/icons/wind_turbine.webp b/public/icons/wind_turbine.webp index 08e3ea2..8ab44f0 100644 Binary files a/public/icons/wind_turbine.webp and b/public/icons/wind_turbine.webp differ diff --git a/public/icons/zero_point_reactor.webp b/public/icons/zero_point_reactor.webp index 7575e07..bcee25e 100644 Binary files a/public/icons/zero_point_reactor.webp and b/public/icons/zero_point_reactor.webp differ diff --git a/src/App.svelte b/src/App.svelte index f1cf774..452d0ef 100755 --- a/src/App.svelte +++ b/src/App.svelte @@ -167,13 +167,55 @@ // Seeded before the first run, so mounting does not re-score a board that // nothing has changed. let previousRoster = rosterSignature(); + let previousAnomaly = configState.anomalyId; + let previousPrestige = configState.prestige; + /* + * The anomaly and the Time Lab research are the second and third inputs of + * this kind and are handled in the same effect, because it is the same + * argument: each changes what the buildings already on both boards are worth, + * not just what the next run may build. + * + * It is tracked separately from the roster rather than folded into one + * signature because the two ask for different work. A tier bought behind a + * standing building changes which tier that *placement* resolves to, which + * is what `rebasePlacements` re-reads; an anomaly changes none of them — the + * same building at the same tier is simply rated differently — so it needs + * the re-score and nothing else. + * + * Like a roster change, this deliberately does not re-optimise. That layout + * was chosen under the old rules and may no longer be the best shape or a + * stable one; what it reports is the honest output of those buildings under + * the new ones, and re-running is the player's call. + */ $effect(() => { 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; + const anomalyChanged = anomaly !== previousAnomaly; 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); + // 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(); }); @@ -187,9 +229,21 @@ 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. It keys on the board's anomaly rather than the player's: + on a shared link those differ, and the theme follows the author's timeline + the board is rated under. -->
{/if} - {#if !readOnly && !uiState.uiHidden} + + {#if !readOnly && !uiState.uiHidden && !uiState.setupHidden} {/if} @@ -420,7 +481,7 @@ height: var(--tap); background: var(--surface-panel); backdrop-filter: blur(12px); - border: 1px solid var(--border-neon); + border: 1px solid var(--border-accent); border-radius: var(--radius-pill); color: var(--text-muted); cursor: pointer; diff --git a/src/app.css b/src/app.css index fa7003d..c96b6a2 100755 --- a/src/app.css +++ b/src/app.css @@ -7,9 +7,15 @@ * full-bleed canvas — `App.svelte` carried a block of `!important` overrides * purely to undo it. Both are gone. * - * The palette below is the neon identity the components already use, lifted - * out of the twenty-odd places that hard-coded `#00f3ff` / `rgba(0,243,255,…)` - * so the mobile work has one place to tune contrast. + * The palette below was lifted out of the twenty-odd places that hard-coded + * `#00f3ff` / `rgba(0,243,255,…)`, so the mobile work has one place to tune + * contrast. + * + * Every token here is named for the **role it plays**, never for the colour it + * happens to hold, because a theme changes the latter and not the former — + * `--accent` spent a while called `--neon`, which stopped being true the first + * time a theme made it purple. A name that describes one theme's answer is a + * name that will be a lie under the next one. See the theme block below. */ :root { @@ -34,10 +40,26 @@ /* ── Surfaces ─────────────────────────────────────────────────── */ --surface-void: #0a0e17; - --surface-panel: rgba(10, 14, 23, 0.88); + /* + * The panel ground, as a bare triplet *and* as the composite most panels + * want. Both exist because a theme has to move them together: the HUD's + * pills, the readout and the sheet each pick their own alpha over the board + * (0.92, 0.94, 0.97), so they compose from the triplet, while anything + * happy with the default reads `--surface-panel`. A theme therefore rebinds + * the triplet once and every surface follows — see the theme block below. + */ + --surface-panel-rgb: 10, 14, 23; + --surface-panel: rgba(var(--surface-panel-rgb), 0.88); --surface-panel-solid: #0d1424; --surface-raised: rgba(255, 255, 255, 0.06); --surface-sunken: rgba(8, 12, 20, 0.93); + /* + * A region set apart *inside* a panel — Setup's foot, which holds the run + * control. A navy tint that separates it from the ground above without + * naming a colour; a theme swaps it for whatever plays that part on its own + * ground (the anomaly theme darkens instead, since navy on purple fights). + */ + --surface-inset: rgba(15, 23, 42, 0.6); /* * What the board sits on. `PixiCanvas` paints its wrapper this colour *and* * hands the same value to Pixi as the renderer's clear colour — a wrapper @@ -59,11 +81,15 @@ * * The law, one line each: * - * 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. * ok — the board only: this building is working. * 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 first two are themed; the four below them never are. * * 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 @@ -77,15 +103,33 @@ * neither. "Neither" is neutral, and most of the header is neither. */ - /* Selection — and the identity, so the wordmark keeps it too. */ - --neon: #00f3ff; - --neon-dim: rgba(0, 243, 255, 0.3); - --neon-faint: rgba(0, 243, 255, 0.15); - --neon-bg: rgba(0, 243, 255, 0.14); + /* + * Selection, and the base theme's identity. Cyan is what it holds here and + * not what it *means* — `AnomalySelector`'s green ring and the anomaly + * theme's lavender are both "selected" too, which is why the name says the + * role. The wordmark is a raster and keeps its own cyan either way. + */ + --accent: #00f3ff; + --accent-dim: rgba(0, 243, 255, 0.3); + --accent-faint: rgba(0, 243, 255, 0.15); + --accent-bg: rgba(0, 243, 255, 0.14); /* Border and glow of a *selected* control; `strong` is a solid one's hover. */ - --neon-line: rgba(0, 243, 255, 0.5); - --neon-glow: rgba(0, 243, 255, 0.2); - --neon-strong: #33f6ff; + --accent-line: rgba(0, 243, 255, 0.5); + --accent-glow: rgba(0, 243, 255, 0.2); + --accent-strong: #33f6ff; + + /* + * The one *filled* primary action — RUN, and nothing else. It is a separate + * role from `--accent` rather than a second spelling of it: `--accent` is a + * stroke or a glyph on a dark ground, where brighter is more legible, while + * this is a fill carrying ink, where brighter is less. The base theme + * happens to answer both with the same cyan; a theme that does not is + * exactly why the two are named apart (see `--action` under the anomaly + * theme, which sits two steps down its own ramp so white clears AA on it). + */ + --action: var(--accent); + --action-hover: var(--accent-strong); + --action-ink: var(--surface-void); /* * Board status. `--status-idle` is matched to the `indicator_idle` pad in the @@ -109,6 +153,60 @@ --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: + * `--accent` 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; + /* A limit reached, or a board that is not yours. Never a plain action. */ --warn: #f59e0b; --warn-dim: rgba(245, 158, 11, 0.4); @@ -151,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 @@ -233,6 +331,125 @@ -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 + * `uiState.boardHasAnomaly` 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. + * On a shared link that is the author's timeline, not the reader's, since it + * is the one the previewed board is rated under. 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 { @@ -275,7 +492,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); } @@ -302,7 +519,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; @@ -313,7 +530,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 d585470..5da5a9e 100755 --- a/src/lib/components/header/GridControls.svelte +++ b/src/lib/components/header/GridControls.svelte @@ -1,4 +1,5 @@ {#if uiState.hasSolverPlacements} -
+
+ {/each} +
+ + diff --git a/src/lib/components/sidebar/BuildingUnlockCard.svelte b/src/lib/components/sidebar/BuildingUnlockCard.svelte index f255418..a0ecf44 100755 --- a/src/lib/components/sidebar/BuildingUnlockCard.svelte +++ b/src/lib/components/sidebar/BuildingUnlockCard.svelte @@ -188,7 +188,7 @@ } .unlock-stripe.on { - background-color: var(--neon); + background-color: var(--accent); } .card-content { diff --git a/src/lib/components/sidebar/BuildingUnlockList.svelte b/src/lib/components/sidebar/BuildingUnlockList.svelte index 3a96e45..c07021a 100755 --- a/src/lib/components/sidebar/BuildingUnlockList.svelte +++ b/src/lib/components/sidebar/BuildingUnlockList.svelte @@ -147,7 +147,7 @@ margin: -0.85rem -1rem 0.75rem; padding: 0.85rem 1rem 0.5rem; background: var(--surface-panel-solid); - border-bottom: 1px solid var(--neon-faint); + border-bottom: 1px solid var(--accent-faint); } @keyframes spin { to { @@ -164,7 +164,7 @@ .tabs-bar { display: flex; background: rgba(10, 14, 23, 0.65); - border: 1px solid var(--neon-faint); + border: 1px solid var(--accent-faint); border-radius: var(--radius); padding: 3px; gap: 3px; @@ -190,9 +190,9 @@ background: rgba(255, 255, 255, 0.02); } .tab-btn.active { - background: var(--neon-faint); - color: var(--neon); - border-color: var(--border-neon); + background: var(--accent-faint); + color: var(--accent); + border-color: var(--border-accent); } .utility-row { diff --git a/src/lib/components/sidebar/ConfigSidebar.svelte b/src/lib/components/sidebar/ConfigSidebar.svelte index 1fd2eb7..04d7c44 100755 --- a/src/lib/components/sidebar/ConfigSidebar.svelte +++ b/src/lib/components/sidebar/ConfigSidebar.svelte @@ -4,7 +4,16 @@ import TemplateSelector from "./TemplateSelector.svelte"; import BuildingUnlockList from "./BuildingUnlockList.svelte"; import SolveModeSelector from "./SolveModeSelector.svelte"; - import { ChevronLeft, ChevronRight, Layers, X, Zap } from "lucide-svelte"; + import AnomalySelector from "./AnomalySelector.svelte"; + import PrestigeUpgrades from "./PrestigeUpgrades.svelte"; + import { + ChevronLeft, + ChevronRight, + FlaskConical, + Layers, + X, + Zap, + } from "lucide-svelte"; /* * The sheet's top region — grabber, header, run slot. Measured rather than @@ -27,8 +36,35 @@ let sidebarWidth = $state(0); $effect(() => { - uiState.sidebarWidth = - compact || uiState.sidebarCollapsed ? 0 : sidebarWidth; + if (compact || uiState.sidebarCollapsed) { + uiState.sidebarWidth = 0; + } else if (sidebarWidth > 0) { + /* + * A `0` out of `bind:clientWidth` means "not measured yet", not "no + * panel" — the binding lands a frame after mount. Publishing it would + * drop the inset to nothing for one paint every time Setup comes back + * from a run, and `.hud`'s `padding-left` transitions, so the whole tool + * stack would glide out and straight back again. + */ + uiState.sidebarWidth = sidebarWidth; + } + /* + * Cleared once this component is gone, the same call `HudToolbar` makes + * for `hudHeight`: a width left behind reserves canvas — and the HUD's own + * `padding-left`, which tracks the same edge — for a column that is not on + * screen. That is the right answer for a preview or a hidden interface, + * which are states the user is *in*. + * + * A run is not one of those. Setup is away for a few seconds and comes + * back on its own, so handing the space to the board and the HUD means + * taking it again a moment later, with the action pill gliding 190px out + * from under the cursor that pressed RUN and back once it lands. The inset + * is held for the duration instead, which is what keeps the whole scene + * still; all the run changes is that the panel is not drawn over it. + */ + return () => { + if (!uiState.setupHidden) uiState.sidebarWidth = 0; + }; }); /** @@ -192,6 +228,20 @@ Buildings + +
{/snippet} @@ -203,8 +253,12 @@ --> {#if uiState.setupTab === "islands"} - {:else} + {:else if uiState.setupTab === "buildings"} + {:else} + + + {/if} {/snippet} @@ -333,7 +387,7 @@ margin: 0; font-size: var(--fs-md); letter-spacing: 1.2px; - color: var(--neon); + color: var(--accent); font-weight: 600; } @@ -341,7 +395,7 @@ * The top-level switch. Deliberately *not* the filled-pill idiom the * category tabs inside the roster use — two rows of identical-looking tabs * stacked on top of one another read as one confusing row of five. An - * underline above a pill is a legible hierarchy; the accent is `--neon` in + * underline above a pill is a legible hierarchy; the accent is `--accent` in * both, because the colour law has one meaning for "this one is selected". */ .setup-switch { @@ -349,15 +403,18 @@ flex-shrink: 0; gap: 0.25rem; padding: 0 1rem; - border-bottom: 1px solid var(--neon-faint); + border-bottom: 1px solid var(--accent-faint); } .switch-btn { flex: 1; + /* Three of these share a 380px panel and a 374px phone, so a long label + has to be allowed to shrink rather than widening the row past it. */ + min-width: 0; display: flex; align-items: center; justify-content: center; - gap: 0.4rem; + gap: 0.35rem; height: var(--ctl); background: none; border: none; @@ -373,13 +430,19 @@ transition: all var(--dur-fast) var(--ease); } + .switch-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .switch-btn:hover { color: var(--text); } .switch-btn.active { - color: var(--neon); - border-bottom-color: var(--neon); + color: var(--accent); + border-bottom-color: var(--accent); } @media (pointer: coarse) { @@ -427,15 +490,23 @@ z-index: var(--z-sheet); display: flex; flex-direction: column; - background: rgba(10, 14, 23, 0.97); + /* + * Its own 0.97 over the board; the theme decides the hue — see the theme + * block in `app.css`. Everything inside follows, because every descendant + * already reads `--text` / `--accent` / `--border` and a theme is a + * rebinding of exactly those. + */ + background: rgba(var(--surface-panel-rgb), 0.97); backdrop-filter: blur(16px); - border-top: 1px solid var(--border-neon); + border-top: 1px solid var(--border-accent); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.6); /* Nothing inside may paint outside the sheet — the belt to `min-height`'s braces, and it makes the top corners clip properly too. */ overflow: hidden; - transition: transform var(--dur) var(--ease); + transition: + transform var(--dur) var(--ease), + background var(--dur) var(--ease); /* Content clears the home indicator when pulled up. */ padding-bottom: var(--safe-bottom); } @@ -536,11 +607,14 @@ flex-direction: column; gap: 0.4rem; padding: 0.6rem 0; - background: rgba(10, 14, 23, 0.9); - border: 1px solid var(--neon-dim); + background: rgba(var(--surface-panel-rgb), 0.9); + border: 1px solid var(--accent-dim); border-left: none; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; - color: var(--neon); + color: var(--accent); + transition: + border-color var(--dur) var(--ease), + color var(--dur) var(--ease); display: flex; align-items: center; justify-content: center; @@ -559,9 +633,11 @@ height: 100%; display: flex; flex-direction: column; - background: var(--surface-panel); + /* Its own 0.88, where the header takes 0.94 — see `--surface-panel-rgb`. */ + background: rgba(var(--surface-panel-rgb), 0.88); + transition: background var(--dur) var(--ease); backdrop-filter: blur(14px); - border: 1px solid var(--border-neon); + border: 1px solid var(--border-accent); border-radius: var(--radius); overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); @@ -569,14 +645,17 @@ .sidebar-head { padding: 0.85rem 1rem; - border-bottom: 1px solid var(--neon-faint); + border-bottom: 1px solid var(--accent-faint); flex-shrink: 0; } .sidebar-foot { padding: 0.75rem 1rem; - border-top: 1px solid var(--neon-faint); - background: rgba(15, 23, 42, 0.6); + border-top: 1px solid var(--accent-faint); + /* A region set apart inside the panel — see `--surface-inset`, which the + theme swaps for a neutral where a navy tint would fight the ground. */ + background: var(--surface-inset); + transition: background var(--dur) var(--ease); flex-shrink: 0; } diff --git a/src/lib/components/sidebar/PrestigeUpgrades.svelte b/src/lib/components/sidebar/PrestigeUpgrades.svelte new file mode 100644 index 0000000..5889fa3 --- /dev/null +++ b/src/lib/components/sidebar/PrestigeUpgrades.svelte @@ -0,0 +1,316 @@ + + +
+
+ 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/components/sidebar/SolveModeSelector.svelte b/src/lib/components/sidebar/SolveModeSelector.svelte index ec15f5c..60393ad 100755 --- a/src/lib/components/sidebar/SolveModeSelector.svelte +++ b/src/lib/components/sidebar/SolveModeSelector.svelte @@ -112,14 +112,14 @@ .mode:hover:not(:disabled), .info-btn:hover { - border-color: var(--neon-dim); + border-color: var(--accent-dim); color: var(--text); } .mode.active { flex-grow: 1.4; - border-color: var(--neon); - background: var(--neon-bg); + border-color: var(--accent); + background: var(--accent-bg); color: var(--text); } diff --git a/src/lib/components/sidebar/TemplateSelector.svelte b/src/lib/components/sidebar/TemplateSelector.svelte index ed6b569..4ca4007 100755 --- a/src/lib/components/sidebar/TemplateSelector.svelte +++ b/src/lib/components/sidebar/TemplateSelector.svelte @@ -186,7 +186,7 @@ gap: 0.5rem; font-size: var(--fs-md); font-weight: 600; - color: var(--neon); + color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.75rem; @@ -219,7 +219,7 @@ .template-item { flex: 1; background: rgba(20, 28, 46, 0.45); - border: 1px solid var(--neon-faint); + border: 1px solid var(--accent-faint); border-radius: var(--radius-sm); padding: 0.6rem 0.85rem; text-align: left; @@ -232,13 +232,13 @@ } .template-item:hover { background: rgba(20, 28, 46, 0.65); - border-color: var(--neon-dim); + border-color: var(--accent-dim); transform: translateX(2px); } .template-row.active .template-item { - background: var(--neon-bg); - border-color: var(--neon); - box-shadow: 0 0 10px var(--neon-glow); + background: var(--accent-bg); + border-color: var(--accent); + box-shadow: 0 0 10px var(--accent-glow); } .name { font-size: var(--fs-base); @@ -247,7 +247,7 @@ transition: color var(--dur-fast); } .template-row.active .name { - color: var(--neon); + color: var(--accent); text-shadow: 0 0 5px rgba(0, 243, 255, 0.4); } /* @@ -297,15 +297,15 @@ } .custom-item { border-style: dashed; - border-color: var(--neon-dim); + border-color: var(--accent-dim); } .custom-item:hover { border-style: solid; - border-color: var(--neon-line); + border-color: var(--accent-line); } .template-row.active .custom-item { border-style: solid; - border-color: var(--neon); + border-color: var(--accent); } .empty-hint { margin: 0; @@ -320,9 +320,9 @@ justify-content: center; gap: 0.4rem; background: rgba(0, 243, 255, 0.08); - border: 1px dashed var(--neon-dim); + border: 1px dashed var(--accent-dim); border-radius: var(--radius-sm); - color: var(--neon); + color: var(--accent); padding: 0.55rem 0.85rem; font-size: var(--fs-base); font-weight: 600; @@ -332,7 +332,7 @@ .add-btn:hover:not(:disabled) { background: rgba(0, 243, 255, 0.18); border-style: solid; - border-color: var(--neon-line); + border-color: var(--accent-line); } .add-btn:disabled { opacity: 0.4; diff --git a/src/lib/pixi/gridPainter.ts b/src/lib/pixi/gridPainter.ts index b420805..6a39d1e 100755 --- a/src/lib/pixi/gridPainter.ts +++ b/src/lib/pixi/gridPainter.ts @@ -22,17 +22,25 @@ import { placementStatus, type PlacementStatus } from "../data/placements"; import { pulseAlpha, STATUS_PULSE, type Pulse } from "./statusPulse"; /** - * The selection cyan, as Pixi wants it. + * The selection accent, as Pixi wants it. * - * This is `--neon` in `src/app.css` (`#00f3ff`), and the two have to stay the - * same number: the board's hover highlight and the ghost pad under a - * restorable obstacle are the canvas half of the same "this is selected" - * language every control in the UI speaks. Pixi takes a numeric literal and - * cannot read a custom property, so this is the one place the value is - * repeated — named here rather than spelled out at the three call sites that - * had it inline, so a change to the palette has somewhere to land. + * This is the base theme's `--accent` in `src/app.css` (`#00f3ff`): the + * board's hover highlight and the ghost pad under a restorable obstacle are + * the canvas half of the same "this is selected" language every control in the + * UI speaks. Pixi takes a numeric literal and cannot read a custom property, + * so this is the one place the value is repeated — named here rather than + * spelled out at the three call sites that had it inline, so a change to the + * palette has somewhere to land. + * + * It is pinned to the *base* theme, and does not follow the one on the shell. + * A theme is a rebinding of CSS custom properties, which is precisely what + * Pixi cannot see, so these two marks stay cyan while the chrome around them + * goes purple. They sit on the board rather than on the chrome, where the + * board's own reserved language is likewise never themed — see the theme block + * in `app.css` — so the mismatch reads as the board keeping its own colours + * rather than as chrome that was missed. */ -const NEON = 0x00f3ff; +const ACCENT = 0x00f3ff; /** * The pad drawn on the tile beneath a building, saying whether it is running. @@ -223,7 +231,7 @@ export class GridRenderer { this.hoverHighlight = new Graphics(); this.hoverHighlight .poly(TILE_DIAMOND) - .fill({ color: NEON, alpha: 0.25 }) + .fill({ color: ACCENT, alpha: 0.25 }) .stroke({ width: 1.5, color: 0xffffff, alpha: 0.9 }); this.hoverHighlight.visible = false; @@ -575,8 +583,8 @@ export class GridRenderer { const pad = new Graphics(); pad .poly(TILE_DIAMOND) - .fill({ color: NEON, alpha: 0.14 }) - .stroke({ width: 1.5, color: NEON, alpha: 0.75 }); + .fill({ color: ACCENT, alpha: 0.14 }) + .stroke({ width: 1.5, color: ACCENT, alpha: 0.75 }); node.addChild(pad); const frameKey = TILE_IMAGE_MAP[ghost.type]; diff --git a/src/lib/simulation/simulator.test.ts b/src/lib/simulation/simulator.test.ts index 403e3f4..6b2c878 100755 --- a/src/lib/simulation/simulator.test.ts +++ b/src/lib/simulation/simulator.test.ts @@ -23,14 +23,17 @@ * 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, Placement, Tile, } from "@reactor2/solver"; @@ -249,6 +252,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], }; } @@ -347,3 +351,238 @@ 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); + }); +}); + +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 x4 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 * 4, 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 ae77c9b..8b74fdf 100755 --- a/src/lib/simulation/simulator.ts +++ b/src/lib/simulation/simulator.ts @@ -1,7 +1,13 @@ 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, + getAnomaly, + type AnomalyDefinition, + type EffectiveBuilding, + type PrestigeScales, +} from "@reactor2/solver"; import { unscoredPlacement } from "../data/placements"; import type { BuildingDefinition, PlacedBuilding, Tile } from "../types"; @@ -30,8 +36,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,47 +56,107 @@ 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; } /** - * 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[], -): 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); - 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++) { @@ -97,10 +167,53 @@ 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); + // 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, 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; @@ -114,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/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..fd85609 100755 --- a/src/lib/state/config.svelte.ts +++ b/src/lib/state/config.svelte.ts @@ -1,27 +1,80 @@ 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"; +import { trackEvent } from "../utils/analytics"; /** - * 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 +116,79 @@ 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) { + // Pressing the card already chosen is not a choice, and would count one + // timeline twice. + if (id === this.anomalyId) return; + trackEvent("anomaly_select", { anomaly: id, from: this.anomalyId }); + this.anomalyId = id; + anomalyStorage.saveAnomaly(id); + } + + /** + * The rules a run is launched under, as analytics params: the anomaly, and + * the research as `id:level` pairs numbered from 1 the way the tier buttons + * are. One string rather than a param per upgrade, so one custom dimension + * covers it and a research added later needs no new registration. + */ + rulesParams(): { anomaly: string; research: string } { + const research = Object.entries(this.prestigeLevels) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, level]) => `${id}:${level + 1}`) + .join(","); + return { anomaly: this.activeAnomaly.id, research: research || "none" }; + } + + 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; + // 1-based like the tier buttons, 0 for switched off. + trackEvent("research_set", { + research: id, + level: id in this.prestigeLevels ? this.prestigeLevels[id] + 1 : 0, + }); + prestigeStorage.savePrestige(this.prestigeLevels); + } + + setResearchLevel(id: string, level: number) { + if (this.prestigeLevels[id] === level) return; + this.prestigeLevels[id] = level; + trackEvent("research_set", { research: id, level: level + 1 }); + 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..6408eee 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,11 @@ 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 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 7d6dde4..cb45357 100755 --- a/src/lib/state/layout.svelte.ts +++ b/src/lib/state/layout.svelte.ts @@ -16,8 +16,13 @@ import { blueprintKey, decodeBlueprint, encodeBlueprint, + getAnomaly, placementTiers, + prestigeScales, + type AnomalyDefinition, + type BlueprintRules, type DecodedBlueprint, + type PrestigeScales, } from "@reactor2/solver"; import { createPlacement, @@ -575,10 +580,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 | null, placements: readonly PlacedBuilding[] = this.placements, ): Promise { - return encodeBlueprint(this.grid, placements, placementTiers(placements)); + return encodeBlueprint( + this.grid, + placements, + placementTiers(placements), + rules ?? undefined, + ); } // ── Preview: someone else's board, opened from a link ──────────────────── @@ -594,6 +613,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 +645,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 @@ -646,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); } @@ -664,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; } } @@ -1045,8 +1088,95 @@ 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, + this.placementAnomaly, ); } + + /** + * 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; + } + + setPrestige(scales: PrestigeScales) { + if (this.#prestige === scales) return; + 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. + */ + /** + * The rules the previewed board's code names, exactly as it named them — + * null off preview, and null on a code that says nothing. Exposed so a board + * re-shared from preview carries the author's rules onward rather than the + * reader's, and silence onward as silence. + */ + get previewRules(): BlueprintRules | null { + return this.isPreview ? this.#previewRules : null; + } + + 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/previewMode.test.ts b/src/lib/state/previewMode.test.ts index ed4332c..cd85e86 100755 --- a/src/lib/state/previewMode.test.ts +++ b/src/lib/state/previewMode.test.ts @@ -15,9 +15,9 @@ import { beforeEach, describe, expect, it } from "vitest"; import { layoutState } from "./layout.svelte"; import { editorState } from "./editor.svelte"; -import { encodeBlueprint } from "@reactor2/solver"; +import { decodeBlueprint, 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,92 @@ 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("re-shares a previewed board under its author's rules", async () => { + await layoutState.loadPreview(await ruledCode(), READER_UNLOCKS); + + // What `shareLayout` hands on from preview: the code's own rules, never + // the reader's — and a re-encode carries them through unchanged. + expect(layoutState.previewRules).toEqual(RULED); + const reshared = await decodeBlueprint( + await layoutState.exportBlueprint(layoutState.previewRules), + ); + expect(reshared.rules).toEqual(RULED); + + await layoutState.exitPreview(READER_UNLOCKS); + expect(layoutState.previewRules).toBeNull(); + }); + + 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 7252162..d1c90d3 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 @@ -294,16 +314,21 @@ class SolverState { const effectiveBuildings = getEffectiveBuildings( BUILDINGS, configState.buildingUpgrades, + configState.prestige, ); if (!effectiveBuildings.length) return 0; const islands = splitGridIntoIslands( grid, canCoolDirectProducer(effectiveBuildings), + configState.activeAnomaly, ); 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,12 +354,33 @@ 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); - const subGrids = splitGridIntoIslands(grid, hasCoolingSupport); - return estimateTotalMaxPower(subGrids, effectiveBuildings); + // 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, + configState.activeAnomaly, + ); + return estimateTotalMaxPower( + subGrids, + effectiveBuildings, + configState.activeAnomaly, + ); } /** How many layouts this solve offers. Below two, there is nothing to cycle. */ @@ -382,7 +428,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}`; } /** @@ -434,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; } /** @@ -446,7 +506,13 @@ class SolverState { bound: number, ): OptimizationResult { const grid = layoutState.grid; - const scored = simulatePlacedBuildings(grid, BUILDINGS, placements); + const scored = simulatePlacedBuildings( + grid, + BUILDINGS, + placements, + configState.prestige, + configState.activeAnomaly, + ); let totalPower = 0; let totalHeatProduced = 0; @@ -487,6 +553,7 @@ class SolverState { this.finishedAt = null; this.elapsedMs = 0; this.isRestored = false; + this.resultAnomalyId = null; } /** @@ -531,12 +598,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 @@ -548,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(); } @@ -581,6 +656,7 @@ class SolverState { finishedAt: number, templateId: string, signature: string, + anomalyId: AnomalyId, ) { const applied = variants[selectedVariant]; if (!applied) return; @@ -595,6 +671,7 @@ class SolverState { toStoredPlacements($state.snapshot(v).placements), ), selectedVariant, + anomalyId, durationMs, finishedAt, }); @@ -612,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, @@ -619,6 +700,7 @@ class SolverState { this.finishedAt, layoutState.activeTemplateId, this.solveSignature(), + this.resultAnomalyId ?? configState.activeAnomaly.id, ); } @@ -663,11 +745,20 @@ 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; + // Setup is hidden for the run, but `solve_done` should still name the + // rules the run started with rather than re-read them at the end. + const runRules = configState.rulesParams(); + // 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; @@ -683,6 +774,7 @@ class SolverState { island: this.#runTemplateId, tiles: countGrassTiles(layoutState.grid), roster: Object.keys(configState.buildingUpgrades).length, + ...runRules, cores: typeof navigator !== "undefined" ? navigator.hardwareConcurrency || 0 @@ -737,7 +829,18 @@ class SolverState { return; this.optimizationResult = progressResult; }, - { attempts: mode.attempts, attemptBudgetMs: mode.attemptBudgetMs }, + { + attempts: mode.attempts, + attemptBudgetMs: mode.attemptBudgetMs, + // 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: this.#runAnomalyId, + prestige: configState.prestige, + }, ); this.activeTask = handle; this.#startWatchdog(mode); @@ -782,6 +885,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. @@ -792,6 +900,7 @@ class SolverState { finishedAt, this.#runTemplateId, this.#runSignature, + winningAnomalyId, ); if (onScreen()) { this.variants = variants; @@ -801,6 +910,7 @@ class SolverState { this.elapsedMs = winningDurationMs; this.lastRunDurationMs = winningDurationMs; this.finishedAt = finishedAt; + this.resultAnomalyId = winningAnomalyId; } donePower = variants[selected]?.totalPower ?? 0; } else if (onScreen()) { @@ -820,6 +930,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 @@ -835,6 +946,7 @@ class SolverState { bound: Math.round(bound), bound_pct: bound > 0 ? Math.round((donePower / bound) * 100) : 0, variants: this.variants.length, + ...runRules, }); } } diff --git a/src/lib/state/timeLabAnalytics.test.ts b/src/lib/state/timeLabAnalytics.test.ts new file mode 100644 index 0000000..d18b886 --- /dev/null +++ b/src/lib/state/timeLabAnalytics.test.ts @@ -0,0 +1,82 @@ +/** + * What the Time Lab reports to analytics. + * + * Every rule here fails silently in the app and only shows up as a wrong + * figure in GA: an anomaly counted twice for a press on the card already + * chosen, or a research level off by one against the tier button the player + * pressed. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const trackEvent = vi.hoisted(() => vi.fn()); +vi.mock("../utils/analytics", () => ({ trackEvent })); + +import { configState } from "./config.svelte"; + +beforeEach(() => { + configState.setAnomaly("none"); + for (const id of Object.keys(configState.prestigeLevels)) + configState.toggleResearch(id); + trackEvent.mockClear(); +}); + +describe("anomaly_select", () => { + it("names the new anomaly and the one it replaced", () => { + configState.setAnomaly("tidal_ascendancy"); + expect(trackEvent).toHaveBeenCalledOnce(); + expect(trackEvent).toHaveBeenCalledWith("anomaly_select", { + anomaly: "tidal_ascendancy", + from: "none", + }); + }); + + it("is not sent for the anomaly already chosen", () => { + configState.setAnomaly("none"); + expect(trackEvent).not.toHaveBeenCalled(); + }); +}); + +describe("research_set", () => { + it("numbers levels from 1, and 0 for switched off", () => { + configState.toggleResearch("absolute_zero"); + configState.setResearchLevel("absolute_zero", 4); + configState.toggleResearch("absolute_zero"); + expect(trackEvent.mock.calls).toEqual([ + ["research_set", { research: "absolute_zero", level: 1 }], + ["research_set", { research: "absolute_zero", level: 5 }], + ["research_set", { research: "absolute_zero", level: 0 }], + ]); + }); + + it("is not sent for the level already set", () => { + configState.toggleResearch("infinite_grid"); + trackEvent.mockClear(); + configState.setResearchLevel("infinite_grid", 0); + expect(trackEvent).not.toHaveBeenCalled(); + }); +}); + +describe("rulesParams", () => { + it("says none when nothing is researched", () => { + expect(configState.rulesParams()).toEqual({ + anomaly: "none", + research: "none", + }); + }); + + it("lists research sorted by id, numbered from 1", () => { + configState.setAnomaly("cryo_nexus"); + configState.toggleResearch("stellar_forge"); + configState.toggleResearch("absolute_zero"); + configState.setResearchLevel("absolute_zero", 2); + expect(configState.rulesParams()).toEqual({ + anomaly: "cryo_nexus", + research: "absolute_zero:3,stellar_forge:1", + }); + }); + + it("reports an unknown stored id as the baseline it resolves to", () => { + configState.setAnomaly("not_an_anomaly"); + expect(configState.rulesParams().anomaly).toBe("none"); + }); +}); diff --git a/src/lib/state/ui.svelte.ts b/src/lib/state/ui.svelte.ts index 3f48465..348a921 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. @@ -279,7 +279,7 @@ class UIState { * and an explicit choice then wins in both directions. * * Settings inverts it for display: every switch in that column means "this - * is happening", and one inverted row would give `--neon` two readings. + * is happening", and one inverted row would give `--accent` two readings. */ #analyticsDisabled = $state( uiStorage.loadPrefs().analyticsDisabled ?? null, @@ -460,13 +460,42 @@ class UIState { } /** - * Whether there is a second board to switch to at all. The toggle renders - * only when this is true — with no solve there is nothing to compare. + * Whether there is a second board to switch to at all — with no solve there + * is nothing to compare. This is what puts the toggle in the layout; + * `canSwitchBoards` is what makes it visible and usable. */ get hasSolverPlacements(): boolean { return (solverState.optimizationResult?.placements.length ?? 0) > 0; } + /** + * Whether the Edit/Solver switch has anything to offer, which is what both + * it and the row it sits in render on. + * + * Not while a run is in flight, and for three reasons that all point the + * same way. The readout is pinned to the run for the duration + * (`statsPanel`), so switching to Edit leaves the card reporting the search + * while the canvas draws the user's board — the one disagreement + * `visiblePlacements` exists to make impossible. The switch back is an edge + * on the run *starting* (`PixiCanvas`), so a layout finishing while the + * user is on their own board lands on a board nobody is looking at. And the + * pill would appear in the middle of a first-ever run anyway, the moment + * the first progress report gives it placements to switch to — on a phone, + * a whole line arriving in the HUD unasked. + * + * The run's board is the one on screen throughout, which is what starting a + * run asks for; the way onto the other one is to let it finish or press + * STOP. + * + * False does not always mean gone. The compact line the toggle sits on is + * not rendered at all, but on a wide screen it shares a centred row with + * the action pill and merely goes invisible, holding its box so RUN/STOP + * does not re-centre under the cursor that just pressed it. + */ + get canSwitchBoards(): boolean { + return this.hasSolverPlacements && !solverState.isOptimizing; + } + /** * Takes the solved layout that is currently on screen into the editable * board, and switches to it. @@ -599,17 +628,49 @@ 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; } - void solverState.runOptimizer(); + this.#beginSolve(); } /** Answers the dialog: `keepBest` defends the layout already on screen. */ startSolve(keepBest: boolean) { this.activeModal = null; - void solverState.runOptimizer({ keepBest }); + this.#beginSolve({ keepBest }); + } + + /** + * The one way a run is started, and the tidying that goes with it. + * + * Setup is unmounted for the duration (`setupHidden`), so the sheet is shut + * on the way in for the same reason `setUiHidden` shuts it: a detent left + * open describes a panel that is no longer rendered, and on a compact + * viewport it takes the whole HUD down with it — which is where STOP is. + * + * In practice it is already closed, because the HUD unmounts while the + * sheet is open and Run is in the HUD. The case it covers is a sheet opened + * on a phone and then run from a window that has since been widened, where + * the detent survives unread until the viewport narrows again. + */ + #beginSolve(options: { keepBest?: boolean } = {}) { + this.setSheetDetent("closed"); + void solverState.runOptimizer(options); } /** Folds the corner readout on a small screen, and remembers it. */ @@ -623,6 +684,30 @@ class UIState { return this.sheetDetent === "half" || this.sheetDetent === "full"; } + /** + * Whether Setup is off screen, and with it both ways back in — the HUD's + * Setup button and the docked panel's own handle. + * + * It goes away for as long as a run is in flight. Everything on its three + * tabs is an input to *that* run — the island it is solving, the roster it + * was planned with, the timeline it is rating against — and a run reads + * every one of them once, at launch. So a press there either cannot reach + * the search at all, or, on the island list, stops it outright; neither is + * what a live panel appears to be offering. + * + * Hidden rather than disabled, this app's usual call, and it replaces a + * half-measure: the run-length row and the anomaly list already greyed + * themselves out while the island list and the roster beside them stayed + * live, so what was on screen was a panel part working and part dead with + * nothing saying which was which. It also hands the screen to the readout, + * which is the one thing a player is actually watching at that moment. + * + * Stop is untouched — it lives in the HUD, which stays. + */ + get setupHidden(): boolean { + return solverState.isOptimizing; + } + toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed; uiStorage.savePrefs({ sidebarCollapsed: this.sidebarCollapsed }); @@ -701,7 +786,16 @@ 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`. A + // previewed board was built under its author's, so re-sharing it passes + // those on (or their absence) rather than restating it under the reader's. + const code = await layoutState.exportBlueprint( + layoutState.isPreview + ? layoutState.previewRules + : blueprintRules(configState.anomalyId, configState.prestigeLevels), + this.visiblePlacements, + ); this.shareCode = code; this.shareUrl = buildShareUrl(code); } catch (err) { @@ -787,6 +881,19 @@ class UIState { return layoutState.isPreview; } + /** + * Whether the board on screen runs under an anomaly — the author's on a + * previewed board, the player's otherwise. It is what the theme keys on: + * the theme says "this timeline is not the ordinary rules", and on a shared + * link the timeline being read is the author's, which is also the one the + * board's figures are rated under (`layoutState.placementAnomaly`). + */ + get boardHasAnomaly(): boolean { + return this.isPreview + ? layoutState.placementAnomaly.rule !== "baseline" + : configState.hasAnomaly; + } + /** * Leaves the shared board and puts the visitor back on their own. * diff --git a/src/lib/storage/storage.ts b/src/lib/storage/storage.ts index ef47c59..a93c6d7 100755 --- a/src/lib/storage/storage.ts +++ b/src/lib/storage/storage.ts @@ -1,7 +1,11 @@ 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 AnomalyId, + type OptimizationResult, +} from "@reactor2/solver"; export interface SavedTemplateData { /** Blueprint code — terrain and hand-placed buildings, see `lib/encoding/blueprint.ts`. */ @@ -18,6 +22,8 @@ export interface StoragePayload { const KEYS = { GRID_STATE: "grid_state", BUILDINGS: "buildings", + ANOMALY: "anomaly", + PRESTIGE: "prestige", UI: "ui_prefs", SOLVE: "solver_result", } as const; @@ -121,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". */ @@ -186,6 +205,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..8ec1f69 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,15 @@ function plan(): IslandPlan { width: 2, height: 1, grid: [] as Tile[][], + buildable: Uint8Array.from([1, 1]), + waterAdjacent: Uint8Array.from([0, 0]), + 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..afd605c 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,37 @@ 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`, 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; + /** + * 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 +138,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);