Skip to content

Anomalies and Time Lab research - #3

Merged
OGMods merged 22 commits into
mainfrom
anomalies
Sep 28, 2026
Merged

OGMods merged 22 commits into
mainfrom
anomalies

Conversation

@OGMods

@OGMods OGMods commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Models the game's prestige layer — the three anomalies (Tidal Ascendancy, Cryo Nexus, Singularity Isolation) and the three Time Lab researches that change building stats — and threads both through the solver, the readout, share codes and analytics.

What changes

  • Rules model. AnomalyDefinition (tagged on rule shape) and PRESTIGE_UPGRADES, both applied as uniform scales through scaleEffectiveBuilding, research first and anomaly second, with waste re-derived at each step.
  • Solver. Each rule is resolved where its inputs settle: terrain bonuses per tile at context build, Singularity per layout in simulateIsland, Cryo's shared pool by handing over the whole board as one island. estimateTotalMaxPower bounds every rule, including neighbourHeatCap and isolationRoom for Singularity. The CLI gains --anomaly.
  • App. Setup's third tab, Time Lab, with research and the anomaly selector; an anomaly rethemes the shell. Setup and the board switch leave the screen for the length of a run.
  • Share codes carry the rules the board was built under, and a previewed board is rated at its author's timeline.
  • Analytics. anomaly_select and research_set events; solve_run / solve_done carry anomaly and research.
  • Pages. Reverts the branch-preview deploy (d993234) now that this branch is merging — main deploys straight to Pages again.

Before / after merging

  • The Pages source is currently the gh-pages branch. After merging, switch it back to GitHub Actions (gh api -X PUT repos/OGMods/reactor2-optimizer/pages -f build_type=workflow), let the deploy job run on main, then delete gh-pages.
  • Register anomaly, from, research and level as custom dimensions in GA.
  • The blueprint version byte is a one-way break for older readers, and every stored solve is invalidated once on upgrade (see CLAUDE.md).

Verification

npm run check, npm run knip and npm test (826 tests) are clean.

The game's prestige update added two things that change what a building is
worth: a timeline-wide anomaly, and three Time Lab researches that scale a
role's stats. This brings both across as far as the search, which does not yet
act on the anomaly.

Time Lab research folds into the roster rather than into the engine.
`prestigeScales()` resolves levels to one factor per role and
`getEffectiveBuildings` applies it, so an `EffectiveBuilding` crosses the worker
boundary with the research already in its numbers. `EffectiveBuilding` gains
`baseValue` — the authored tier value, passed through every scaling helper
untouched — because a placement's tier is resolved back out of the reported
figure, and reporting a scaled one resolves to a higher tier and scales twice.

`ANOMALIES` is a tagged union over the rule shape rather than the anomaly's
name, so a re-parameterised anomaly is a table entry and a new rule shape is a
compile error. `"none"` is an entry rather than an absence, and `getAnomaly` is
total: the id arrives off localStorage and the worker boundary.

Blueprint codes grow a third section carrying the rules a board was built
under — the anomaly byte, then one pair per research. Tiers said what the
buildings were, and that stopped being the whole story once research changed
what a tier is worth. It decodes to `null` when a code does not say, which is
not the same as "no anomaly, no research".

Setup gains a Time Lab tab holding both, and three surfaces go purple while an
anomaly is selected. The board's own reds and ambers are deliberately left
alone.

The solver accepts an anomaly and does not act on one: it is threaded through
`SolveOptions`, the worker request and `IslandContext`, which is where the
stages will read it.
The anomaly and Time Lab semantics had been modelled from the in-game
description text, which was enough for the numbers and not for the rules. The
three anomaly classes, the research classes, the getters that apply them,
CoolingNetwork.cs and GeneratorBuilding.TickGeneratingEnergy have since been
read directly. Most of what was written down survives; four things do not.

**There is no full-cooling admission test.** A power source produces whenever it
is not overheated or booting, proportionally to the heat it received, and
cooling is not consulted. Uncooled waste accumulates into a store and latches
the building overheated at 99.5% of its capacity, before that tick's cooling. So
an undercooled layout does not go dark — it saws between running and latched.
"Cooling is all-or-nothing" stays the rule this project optimises against, but
as the criterion for a *sustainable* layout rather than as a thing the game
does, and the section now says which it is. One consequence worth having: the
overheat threshold can never bind, so scaling it changes nothing here.

**Off the board counts as water.** The game has one global map with open water
between islands and these eight boards are rectangles cut out of it, so the
water past an edge is real and simply absent from the blueprint. It moves the
shipped maps barely (+4 tiles on island3, +2 on island7) and is the entirety of
Tidal Ascendancy on a custom island, whose blank 10x10 of grass contains no
water at all. The trap for the solver is that IslandSubGrid's padding is clamped
to the board and cannot tell an off-board neighbour from the window's own edge.

**Waste is derived, not scaled.** The game recomputes snapToAuthoredPrecision(
heat - energy) from the fully scaled pair, so research and anomaly have to meet
at a single multiply with one snap after it. scaleEffectiveBuilding multiplies
waste directly, which is right for one factor and wrong for two.

**Cryo Nexus drops the minimum island size to one tile.** The 2-and-3-tile
floors exist only because cooling must cross a tile boundary. A lone tile takes
a heat sink paying into the pool, or a direct producer the pool pays for — 21
tiles across the shipped maps that no other rule in the game can use.

Settled without change: Tidal covers all four roles and does not stack;
Singularity reads placement rather than state, scales only generators, and
neither takes nor gives the penalty to a wind turbine; turbines draw from the
Cryo pool; research and anomaly compose multiplicatively; Stellar Forge is heat
producers only, which is reactors. Time Lab research also gets a section of its
own, having had none.
The game holds no waste figure at runtime: its getter is
`(HeatPerTick - EnergyPerTick).SnapToAuthoredPrecision()` over whatever heat and
energy currently are, so waste is re-derived from the scaled pair every time
something scales them. `scaleEffectiveBuilding` multiplied the incoming waste
instead, which agrees to about fifteen digits and disagrees in the last —
generator7's top tier under Tidal's x1.67 lands on 3.6907e21 derived against
3.6906999999999996e21 scaled. The golden fixtures assert exactly.

This is also what makes the function composable, which an anomaly needs. The
game applies research in the SO getter and the anomaly in the runtime getter,
one after the other, and `(authored x research) x anomaly` is not the same
double as `authored x (research x anomaly)`. So the two arrive as two successive
calls and each re-derives waste from the pair it produced.

Only generators and direct producers derive one. A cooler and a reactor have no
energy figure, so `heat - energy` would quietly turn a reactor's whole output
into waste needing cooling, and a cooler's whole cooling into waste.

No fixture moves: every one of the catalogue's 55 producer tiers already
satisfies `waste == snap(heat - energy)`, so at a factor of one the derivation
is the identity, and nothing in the fixtures is scaled. Regenerated to confirm —
byte for byte.
…he rules

Two board-level facts the search will need, both of which have to be settled
before the grid is decomposed rather than after.

`computeWaterAdjacency` runs once on the whole board and each `IslandSubGrid`
carries a `waterAdjacent` mask indexed like `buildable`. It cannot be resolved
from the window: the padding is clamped to the board, so for a tile on the
board's own edge the off-board neighbour is not in the window at all, and the
natural bounds-check-and-skip reads it as inland. Since off the board counts as
water — one global map, open water between islands, these boards cut out of it —
that would rate a custom island's entire perimeter wrong, which is the whole of
Tidal Ascendancy there.

`minIslandTiles` replaces the inline 2-or-3, and returns 1 under a shared cooling
pool. Both ordinary floors hold only because cooling has to cross a tile
boundary; pooled, a lone tile takes a cooler that pays into the pool or a direct
producer the pool pays for. That is 21 tiles across the shipped maps that no
other rule in the game can use. `splitGridIntoIslands` therefore takes the
anomaly, and every caller passes the one its run is planned under — including
the app's two estimates, which would otherwise decompose the board differently
from the run they describe.

Nothing reads `waterAdjacent` yet, and no fixture moves.
The first anomaly the search acts on. A terrain bonus is decided by the board
alone, so `terrainScales` resolves it into a per-tile multiplier once when the
context is built and `IslandContext.rate(tile, building)` is a lookup — the rule
costs the search nothing, and `uniformRating` makes it the identity under every
other anomaly.

It is applied where a building is written onto a tile rather than where its
figures are read. A step of the walk writes one or two tiles and then simulates
the island, which reads every occupied tile's three figures, so the write is
some 25x less work and `simulateIsland` — the hot path, and the definition of
what a layout scores — is untouched. Resolution is a lookup and not a multiply
because a scaled building's waste is snapToAuthoredPrecision(heat - energy), and
that snap is a string round-trip.

`put()` becomes the only way a building enters a placement, so the bonus cannot
be missed at one of three dozen sites; a miss would be silent, the layout simply
being worth less than it is. `rate` is idempotent, which is what makes that
safe: the search swaps buildings between tiles and restores them when a move is
rejected, handing back objects it was already given, and without it a restore
would scale a scaled building. `downgradeOversized` also rates each candidate
before comparing it, since the ladder is the plain roster while the building it
replaces and the load it measured are in the tile's units.

The readout goes with it, or the two would print different figures for the same
board: `simulatePlacedBuildings` takes an anomaly, `layoutState` holds one the
way it holds research, and a previewed board is rated under the anomaly its code
names rather than the reader's.

A bonus is genuinely not free power here. A shore generator makes x1.67 the
waste while an inland cooler still covers x1, so a cluster straddling the coast
goes offline, and a layout optimised under the base rules can score lower
re-rated under Tidal. The search has to keep each cluster on one side of the
shoreline, which is a harder problem than the uniform one: on Magma Rift a 6s
run lands under the baseline and a 30s run lands 8% above it. The seeding
heuristics still pick candidates by unscaled roster figures, which is where that
gap lives.

No fixture moves — the fixtures never carry an anomaly.
The flag threads an anomaly through both CLI axes — the per-island pool and the
per-attempt session — and validates the id against ANOMALIES rather than leaving
it to getAnomaly, which is total and would silently run the base rules on a
typo. Fine for an id off localStorage, useless for one a person just typed. A
run names its anomaly in the summary when it has one, since the power figures
mean something different under each set of rules and a solves/ directory of
codes otherwise says nothing about which is which.

Writing it surfaced a bug in yesterday's Tidal measurement: `solve` takes an
options object, not a positional anomalyId, so the scratch benchmark passed it
as an ignored seventh argument and both runs were unmodified. The real figures,
through the CLI on Magma Rift at 20s with a fixed seed: 142AC baseline against
181AC under Tidal.

That run also reported 119.9% layout efficiency, which is a bound that can be
beaten and therefore not a bound. `estimateTotalMaxPower` now takes the anomaly
and rates each island at its best tile: the estimate is homogeneous in the
roster's figures, so rating a whole island at its best tile dominates rating
each tile at its own. Loose where only part of an island qualifies, which is the
right way to be wrong — a bound that can be beaten is worthless, one that is
generous only makes the efficiency figure read low. Magma Rift now reads 72.7%.
`put()` was meant to be the only way a building enters a placement, but the pass
that introduced it matched tile expressions with a character class that stops at
the first `]` — so the four writes indexed by `best.neighbors[i]`,
`shared.reactorTiles[i]` and the like were left as plain assignments. The seed
therefore rated a hub's generator and not its reactors or coolers.

The effect is on search quality rather than on output: `downgradeOversized`
rebuilds the finished layout from the plain roster through `put`, so what leaves
the solver is rated either way. Across three seeds on Magma Rift at 15s under
Tidal the fix is worth about a point (mean 180.3 to 181.3), which is inside the
noise — it is committed because every write being rated is the invariant `put`
exists to provide, not because the measurement demands it.

A test pins the agreement that matters instead: the placements a solve reports,
rebuilt and re-rated the way the app rebuilds them, score the power the solve
reported. Under a terrain bonus a gap there is 67% on every shore tile.

Tile-aware seeding was tried and is not here. Ranking a candidate hub by its fit
times the tile's multiplier, and restricting a hub's tiles to one class so it
cannot straddle the coast, both came back at or below the unchanged search over
three seeds. `powerPerTile` orders a greedy claim that later stages rewrite, so
skewing it toward the coast mostly moves which tiles are claimed first. Both
docs record the negative result so it is not re-attempted blind.
The second anomaly the search acts on, and the first whose multiplier depends on
the layout rather than the board. A generator's rating is a function of what its
neighbours are, so placing one re-rates up to eight other tiles: it cannot be
folded into a tile the way a terrain bonus can, and it cannot be settled where a
building is written.

It is resolved per layout in `simulateIsland`, the one place a whole layout is
in hand. The placement is copied into `ctx.ratedLayout` — held by the island
rather than allocated per call, the same argument every other buffer there is
held for — the affected role's tiles are re-rated, and every figure is read
through that. The neighbour scan reads the original placement on purpose: the
test is on what a neighbour *is*, which no rating changes, so there is no order
to get right. `rateIsolated` is a lookup over two variants per roster entry, for
the reason `rate` is a lookup — the scaled waste is a snapped difference and the
snap is a string round-trip.

Modelled by role rather than by the catalogue's grouping, so a wind turbine
neither takes the penalty nor imposes it, and coolers and reactors are rated
exactly as authored however tightly they are packed. One neighbour costs what
five do.

Worth recording what the rule turns out to be worth: close to nothing. x2.5
scales a generator's *intake*, which is a capacity — fed by the reactors it
already had, a bonused generator fills to 40% and produces exactly what it did
before. Only the penalty bites, and it is avoidable by keeping generators apart.
Map 3 at 15s: 1.4075e23 under the anomaly against a 1.4202e23 baseline, with the
baseline layout re-rated at 1.2849e23 — the search recovers most of the penalty
and finds no gain. Map 1 is identical to three significant figures either way.

Sizing the composition retarget from the isolated rating was tried, since it is
the only stage that can propose the different mix the bonus would need, and
changed nothing across three seeds. It is not in the tree.

No fixture moves.
The last of the three, and the only one that reaches past a building's own
figures. The game's "island" is the map, so under a shared pool the components
`splitGridIntoIslands` produces are not independent — which is the assumption
the worker pool, the budget split, `IslandBest` and `variants.ts` all rest on.

The resolution is to stop producing them. `wholeBoardIsland` hands the board
over as a single island, which makes the pool board-wide by construction and
leaves every one of those assumptions true. It costs nothing elsewhere:
`runDistribution` already scopes its round budget and its repair to each
connected component of the supplier/consumer graph, a finer partition than the
island, so heat stays adjacency-bound. It is also what brings in the ground no
decomposition keeps — every grass tile is on the one island, the 21 lone tiles
across the shipped maps included, so `minIslandTiles` loses its Cryo case rather
than gaining one.

The price is per-island parallelism: one island is one pool task. Small on the
shipped maps, where one landmass already holds ~95% of the budget, and it buys
the whole board-level problem for nothing — the alternative is describing each
component by a frontier of power against net cooling contributed and combining
those under one scalar budget.

The pool itself needs no second code path. `simulateIsland` replaces the cooling
distribution with the game's DistributeCryoArea — total the coolers, total what
the sources are owed, serve everyone the same fraction — and because the
fraction is common, a short pool leaves every source under its waste at once. So
the ordinary per-producer online test turns it into the board-wide
all-or-nothing the rule describes, with no board-level flag anywhere.

The x0.88 is not part of the pool. It is a uniform scale on the cooler role
applied through `ctx.rate`, because the game applies it to
CoolerBuilding.CoolingPerSec: it is what a cooler is worth and what the game
shows for it. `contribution` is renamed `coolerMultiplier` to say so, matching
the game's own field, and `rate` gains a per-role factor beside its per-tile one
— only ever one of the two is in force, since only one anomaly runs at a time.

Worth real power, and most where the board is fragmented, since the x0.88 has to
be earned back first: map 7 at 25s goes 271AC to 294AC, map 3 at 20s 139AC to
145AC, and map 1 — one landmass, already at 98% of its bound — is unchanged.

No fixture moves.
A review of the branch turned up one shape of mistake repeated across it: a
figure resolved under an anomaly compared against a figure that was not. The
rules were threaded correctly and applied correctly; what was missed is that
once a tile can rate a building, every comparison against that building's
figures has to be asked in the tile's units too.

The bound was the reproducible case. `islandMaxScale` answered only for
`terrain_affinity` and returned 1 for the rest, so an isolated generator's x2.5
intake sat outside it: a generator-bound roster on a 3x3 under Singularity read
163.8% layout efficiency, which makes the figure worthless rather than
generous. It is now an exhaustive switch with no `default`, so a fifth rule
shape fails to typecheck here instead of silently reading 1, and the isolation
case returns the larger of the two role factors. Scaling the result is sound
because `estimateIslandMaxPower` is homogeneous of degree 1 in the roster's
figures, so scaling the answer is the same as scaling every figure — loose
where only part of an island qualifies, which is the trade the function already
took for terrain. The same case now reads 65.5%.

Four sites in the search had the mismatch. The under-fed-reactor move compared
a scaled delivery against an authored capacity, so 7675b1f's deliberate
redefinition of `baseValue` left it flagging reactors only below 1/k fill — at
Stellar Forge x5, below 20%. The report row gains `ratedValue` beside the
authored value rather than the two sharing a field again. The retarget gate
sized its targets off the plain roster and broke out of the loop on the first
one, attempting 1 of 3 targets under Tidal against 3 of 3 at baseline; it now
scales the target by what the island can rate. The six no-op-move guards
compared base objects against rated ones and so stopped skipping, spending
budget re-simulating layouts that had not changed. Right-sizing compared an
isolation-rated load against unrated capacities and kept generators at tiers
nothing needed — real money, invisible in power.

None of it moves a fixture: every one of these is the identity at baseline, and
the goldens regenerate byte for byte.

The readout had the mismatch in the other direction, and it was the one a
player could see. `effectiveAtValue` takes no anomaly, so the ceiling under a
used figure was the authored tier: a shore cooler printed 8.35AC / 8AC, a used
figure larger than the total it was measured against, and a Cryo cooler at full
tilt could never reach its own. `ratedPlacementAt` answers from the scorer
instead, which is also what resolves Singularity correctly — a rating that is a
function of neighbours cannot be recomputed in a component.

`uniformRating` meant `tileScale === null`, so it was true under Cryo while
every cooler was scaled. Nothing read it, which is the only reason it was not
already a bug; its docstring invites a stage to skip `rate` on the strength of
it, which would have returned a layout 13.6% over-cooled on paper that the game
shuts down board-wide. It now promises what its name does.

The CLI wrote codes that did not say what they were solved under, so a Tidal
layout previewed at roughly baseline; codes now carry rules, filenames carry
the anomaly id, and every header names it. Both byte maps become closed unions,
so an anomaly or a research added without a byte is a compile error rather than
a share code silently written as `none`.

On the tests: a mis-indexed read of `waterAdjacent` that forgot the window
origin passed the entire suite, while mismatching 28 to 166 tiles on every
shipped map — the two boards asserted against mask values both had a pad origin
of 0. That is pinned against all eight codes now. The waste-composition test was
a tautology that held under the regression it named, and the ordering CLAUDE.md
calls load-bearing was pinned nowhere; both are on figures that differ in the
last bit, where this catalogue's divergences actually live. The search now runs
end to end under each rule, and every seed write is asserted rated.

Two things are deliberately unchanged. `moveScale` is calibrated off the plain
roster, so the walk runs colder under any anomaly — but the comment above it
records that the previous hotter calibration was itself the bug, and this is a
tuning constant, so it is documented as an open question with the measurement
needed rather than changed blind. And the version byte is a one-way break: an
older reader takes it for a width and decodes a 13x13 board as 1x13 without
throwing. That is not fixable from this side; it is written down, along with the
stored solves this release invalidates once.

The retarget stage came back inside run-to-run noise on the seeds measured, so
what it restores is the stage, not power.
A review of the anomaly work on this branch, measured against a worktree of
main. Nothing was threaded wrongly and no rule was misapplied; what was found
is three places where a stage reasoned at the wrong granularity, each silent in
the way this class of mistake always is -- a layout simply worth less than it
says, or a figure printed beside it that nobody can check.

The base path is untouched throughout. Seed construction and a fixed 40k-step
walk on maps 1, 3 and 7 return bit-identical power against main, throughput
sits inside noise, and the goldens regenerate byte for byte.

**The counting stages counted the roster, not the board.** `solveIsland` drew
its four pools straight from `effectiveBuildings`, so under a cooling pool the
hub fits, the composition targets and the polish candidates all valued a cooler
at its authored figure while the board holds x0.88 of it. The retarget was the
visible casualty: every target it proposed on maps 3 and 7 was 8-14% short of
the waste it planned to make, and under a pool a short board is offline
entirely, so the stage could never return a layout that beat the one in hand.
`ctx.rateRole` is the per-role half of `rate` on its own, and the pools are
rated through it once; `rate` is idempotent, so an entry that already carries
its role's rating is handed straight back at the write. Map 7 at 15s over three
seeds went 288/281/288AC to 294/294/294AC, map 3 144/144/144AC to 145/145/144AC.

**The bound scaled its own answer where it could have scaled its inputs.** A
rule that scales a whole role is a property of the roster, so it now goes into
the roster the LP runs on (`roleRatedRoster`) rather than onto the LP's result.
Scaling the result by the largest factor rated reactors and coolers up as well:
under Singularity the bound was 2.3x the tight one, so a layout at 97.5% of
what a generator-bound roster can reach read as 51% layout efficiency. A bound
that is generous only makes the figure read low, but this was generous enough
to make it meaningless.

Terrain is the half that genuinely cannot go into the roster, because which
tiles carry it is a property of the board -- and rating a whole island at its
best tile is very loose where half of it is inland. `estimateIslandBound` now
splits an island into its shore and inland tile counts and runs the LP over
both classes at once: a shore reactor may still feed an inland generator, since
the bound relaxes adjacency away, and the tiles left over are filled by a
fractional knapsack over power per unit of cooling. Integer layouts are inside
that relaxation, and a mixed island returns the lesser of it and the old
whole-island figure, so it is never looser than what it replaces. All-shore and
all-inland islands return the old expressions bit for bit. Roughly 24% tighter
on every shipped map: map 3 goes 248AC to 191AC, against a 15s solve of 178AC.

**The Cryo seed spent its best tiles on buildings that need no neighbours.**
Under a pool a cooler contributes nothing by where it stands, so every
well-connected tile should hold engine and the coolers belong on the scraps --
which is exactly what the seed did not do: each hub ceiled its own coolers onto
its own neighbours, over-cooling by ~40%, and left every one-tile patch empty
because no hub fits on one. It is sized against a phantom cooler of infinite
cooling now, so the fits ask for none; the cooling is charged as a board-wide
debt instead, counted into each candidate's rank, reserved out of the free
tiles so a hub cannot crowd out what keeps it online, and paid off afterwards
onto the least-connected tiles first. Seed efficiency went from ~76% of the
bound to 98-99% with no tile left empty. Map 7 now returns its bound at both 5s
and 15s in 6 of 6 runs; map 8 at 5s gains 1.9%; map 3 was at its bound already
and now reaches it every run rather than two in three. The local passes stay,
and the stronger of the six seeds starts the search, so a pooled seed that came
back weaker could not cost anything.

Two candidate changes were measured and are NOT here, with their numbers left
in place so the next person does not re-run them blind. Scaling the annealing
temperature by the island's rating ceiling -- the open question the docs have
carried since Tidal landed -- moved Tidal on map 3 from 176/178/179AC to
179/181/178AC and Singularity on map 7 from 271/271/271AC to 268/267/271AC,
inside run-to-run noise either way. Aiming the walk's "cool a starved producer"
move at an empty tile under a pool, where adjacency buys nothing and the move
otherwise writes over the reactor feeding the producer it means to save,
changed nothing at all on maps 3 and 7.

One existing test was wrong in its premise rather than its assertion: it called
a bare 3x3 all-shore, when the centre tile has all eight of its neighbours on
the board and is inland. The two-class bound is tighter there, which is what
caught it.
Changing the anomaly with a solve standing re-rates it and says nothing.
`rescoreResult` re-reads every variant's figures under the new rules without
searching again, which is the right call — that layout was chosen for the old
roster and may no longer be the best shape, or a stable one, so re-running is
the player's decision. But the figures it prints afterwards are honest about a
board whose *shape* belongs to a timeline nobody is in any more, and nothing on
screen distinguished that from a solve found under the rules now in force.

`solverState.resultAnomalyId` is the anomaly the shortlist was actually
searched under, and its whole value is that it may disagree with the selection.
It deliberately does not follow `rescoreResult`: an earlier pass moved it to
the current anomaly there, which erased the warning it exists to raise and let
Run's "keep the better one?" dialog defend a layout that had never been
searched under the new rules at all. It is captured at launch instead
(`#runAnomalyId`, beside the island and the signature a run already pins), so
moving the selection mid-run cannot change what the search returns, and
`AnomalySelector` disables its cards while one is in flight for the same
reason — the search reads the anomaly once and a press there could only
silently retarget the next run.

The readout grows an Anomaly row naming the current selection, with a `--warn`
note beside it — not `--status-idle` or `--danger`, which the colour law
reserves for what a building on the board is doing — on the one reading where
the layout was searched under something else. Run skips the re-run dialog
across that gap, since a layout found under other rules is not a bar a fresh
run has to clear; the comparison is against `activeAnomaly.id` rather than the
raw stored id, which is whatever localStorage held, or an unrecognised string
would never match a resolved one and the dialog would be skipped for good.

`SavedSolveData` carries the anomaly so a reload still knows. That is not the
same fact as the one inside `signature`: re-scoring re-files a record under the
new signature without searching, so the signature says what it was last rated
against and this says what it was found under. A record written before the
field has none, and a matching signature already settles it there.

The purple an anomaly puts on Setup, the readout and Run now reaches the rest
of the chrome — the header, the overflow menu, every dialog through
`ModalShell`, both palettes, the mode row, the Undo/Redo/Run and Edit/Solver
pills, and the size steppers. Each re-points the inherited tokens rather than
restyling anything, so nothing new learns that anomalies exist; `--neon` goes
with them only inside the dialogs and the steppers, which read it directly for
a title or a button where Setup's own ground would otherwise have carried it.
`--text-dim` travels with the ground wherever a label that is not a disabled
control sits on it, since it is tuned to clear AA on the navy panel and does
not on this one.

One thing that is not about anomalies at all: both palettes were pill-radiused
around boxy items, and a pill's corner tracks half its height, so at ~70px the
outer curve swept well inside the `--radius-md` items and clipped their
corners. Both take `--radius-lg` at every width now, which is what the phone
breakpoint had already been doing for the same reason.
An anomaly was spelled out eleven times. Ten components each carried
`class:anomalous={configState.hasAnomaly}` and a `.anomalous` rule that
re-pointed *a different subset* of the same tokens, which is how the HUD's
ground went purple while the selected tool standing on it stayed cyan, and how
`BuildingPalette` came to remember `--text-dim` where `ObstaclePalette` beside
it did not. Every new panel was another place to remember, and forgetting was
silent: the panel simply looked like chrome someone had missed.

`App.svelte` now puts `data-theme="anomaly"` on `.app-shell` and one block in
`app.css` rebinds the tokens under it. Every surface inside the shell follows —
the header, the HUD's pills and ribbons, the readout, Setup, the overflow menu,
the dialogs, the toast. No component reads `hasAnomaly` and none of them
carries a rule about anomalies at all; six lost the `configState` import
outright. It hangs on the shell because that is the one place allowed to see
`configState` beside everything else, and because a theme applied in eleven
places is eleven places to get it wrong.

Three tokens make the rebinding possible, each replacing something that was
hard-coded in the base theme too. `--surface-panel-rgb` is the ground as a bare
triplet, so the eight rules that wrote `rgba(10, 14, 23, 0.9x)` compose from it
and each surface keeps its own alpha over the board — the sheet 0.97, the
readout 0.94, the HUD's pills 0.92, the docked panel 0.88. Alpha is the
component's business and hue is the theme's, so selecting an anomaly changes
the colour and nothing about how much board shows through. `--surface-inset` is
Setup's foot, which tints navy in the base theme and darkens under this one
because navy on purple fights. And `--action` is the one *filled* primary, RUN:
a fill carrying ink, where brighter is less legible, against `--accent`'s
stroke on a dark ground, where brighter is more. The base theme answers both
with the same cyan, which is why they read as one token until a theme needed
them apart — the selection lavender carries neither ink at AA.

`--neon` is now `--accent`, with its family and `--border-neon`. The name
described one theme's answer and stopped being true the first time a theme made
it purple, which is worse than a vague name because it reads as true. The law
already stated the role. `ModalShell`'s own `--accent` had to move to
`--modal-accent` in the same pass: left alone, its `var(--neon)` default became
`--accent: var(--accent)` on the same element, a cyclic dependency that CSS
drops as invalid at computed-value time — the dialog's border, glow and title
would have gone silently colourless. `gridPainter`'s `NEON` is `ACCENT` and now
says what is true of it: Pixi cannot read a custom property, so it is pinned to
the base theme and does not follow the shell.

Two things stay out of a theme deliberately. The board's own language —
`--status-ok`, `--status-idle`, `--danger`, `--warn` — is never rebound, since
the pad under a building, the pulse that breathes it and the readout's red
Cooling row mean the same thing under every timeline, and an anomaly is
precisely when a player most needs them to. And the game's chooser palette
(`--anomaly-card`, `--anomaly-selected`, the benefit/drawback pair) is not a
theme either: those are `AnomalySelector`'s own colours, sampled from a screen
in the game, and only the ground under them is themed. That is why the prefix
now means one thing instead of two.

Driven in a real browser, both themes, desktop and phone: the base theme is
unchanged, and under an anomaly the readout's Idle amber and Overheating red
are pixel-identical to the base shot while the ground beneath them is purple.
Per the earlier decision to theme everything, marks that used to sit cyan on a
purple panel now follow it — the HUD's active tool, the selected building in
the palette, the readout's accents — and the Setup button and collapse handle
take the selection lavender rather than the game's badge purple, which was an
anomaly-only emphasis with three different base values and so not a themeable
role at all.
A repository has one Pages site, and until now `main` was it: the deploy job
handed a single artifact to actions/deploy-pages and any other branch's build
had nowhere to go, so looking at this branch's board meant running it locally.
The site becomes a `gh-pages` branch instead, with `main` at the root and every
other listed branch in a directory named after itself -- `anomalies` serves at
`/anomalies/`, beside the root rather than in place of it. `base: "./"` is what
makes that free: the identical build serves from either depth, where a
configured base would be wrong at one of them.

`on.push.branches` is the single place a branch opts in. The job derives
everything from the ref it was given, so there is no second allow-list to fall
out of step with it, and no branch but `main` can reach the root.

Dropping a `preview/` prefix is what costs something, and it is worth being
explicit about what. A preview now sits among the root's own `index.html`,
`assets/` and `icons/`, so publishing the root can no longer clear everything it
finds: it has to know which top-level directories belong to other branches. A
list of names here would be exactly the second list the trigger already is, so
each preview drops a `.preview` marker instead and the root publish keeps
anything carrying one -- the site records what its own previews are. That marker
is also what the cleanup workflow tests before it deletes, so it cannot take a
directory that is part of the root build.

Two names could now collide with the build, and both are refused rather than
merged, because either quietly overwrites part of a live site: a branch named
after something the build puts at the top level, and a branch name containing a
slash, which would bury its marker below the depth the root clear looks at.

Deletion is its own workflow because nothing in the publish path can notice it
-- that job only ever touches the directory it is publishing -- so a preview
would otherwise outlive its branch forever. Both jobs share one concurrency
group and never cancel in flight: they push to the same branch, and a
half-applied site is undefined. Each loses a race by rebasing rather than
failing.

The deploy job's `pages:`/`id-token:` pair gives way to `contents: write`, since
publishing is now an ordinary commit, and the build travels as a plain artifact
rather than a Pages one. Cancelling a superseded run is narrowed to pull
requests: a push may publish, and abandoning that half-done is the thing the old
comment was guarding against.

The publish and cleanup scripts were driven against a throwaway repository
first: a preview published before any root existed, a root publish and a
rebuild both kept it, the two collision guards refused, and cleanup removed a
preview while refusing a root directory.
The bound rated every generator at the isolated bonus and the search
counted them at neither rating, and both were asking for layouts no
board can hold. Three geometric facts the LP had been relaxing away:

`neighbourHeatCap` — heat crosses a tile boundary and nothing else, so a
generator's intake is the reactors beside it, and the all-or-nothing
cooling rule wants coolers beside it too, out of the same eight tiles.
The cap is homogeneous like the LP, so no uniform rule can make it bite;
a rule that scales one role is what it takes, and x2.5 puts the top
generator at 181% of it. The shipped maps' bound sat 5% above anything
the board allows, reading 86.5-90.0% layout efficiency for layouts that
were not 86.5-90.0% of anything; it now reads 89.4-95.0%. Base, Cryo and
Tidal are unchanged to the last digit.

`isolationRoom` — isolated generators are pairwise non-adjacent, so they
are an independent set, and on a generator-bound roster the split wants a
third of the island to be one. Gale Hills at generator7 tier 2 was asked
for 17.4 where the island admits 15. Maximum independent set is NP-hard
(5.4M nodes on 49 tiles, unfinished on 77), so the ceiling comes 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 instead of running the bonus out to the
whole island. Five of eight maps move; full unlocks do not.

`generatorCapacityTable` — the same table, for the stage that decides
what to build. `targetCompositions` counts, and what a generator is worth
under this rule is two numbers, neither of them in the pool it draws on.
With everything unlocked but generator7 at tier 2 the search plateaued at
58.8AC from 5s to 150s: the walk found the penalty-avoiding half and
never the different mix. Sizing at the bonus alone does not fix it — that
asks for 18 generators where 15 can be kept apart, and the arrangement is
rejected — but sizing through the room reaches 60.7AC, which is what an
unconstrained annealer finds given 30x the budget, and 4.6% above the old
plateau's mean over ten runs. `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.

The docs recorded that sizing the retarget from the isolated rating "was
measured and changed nothing", which is why this sat unfixed; both now
carry the missing half.

Verified by 24M brute-forced layouts across cap-biting and
generator-bound rosters on small boards, plus an exhaustive 262144-layout
case in the suite: nothing beats either bound and something attains each.
Everything on Setup's three tabs is an input to the run already under way
— 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 offer.
Half of it already said so: `SolveModeSelector` and `AnomalySelector`
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. `uiState.setupHidden` is that rule stated once,
for the whole panel and both ways back into it — the HUD's Setup button
and the docked panel's own handle. Hidden rather than disabled, the usual
call; the two `disabled` flags stay as backstops for the frame between
the run starting and the panel unmounting. Stop is untouched, being in
the HUD.

Unmounted rather than folded, because folding is a preference the user
owns: `collapseSidebar` persists, so a run would hand them back a panel
that had shut itself while they were watching the board. That is also why
`dismissConfigPanel` now sits the run out — a press on the view toggle
mid-run reached it through the HUD's own pointerdown.

The sheet is shut on the way in. `#beginSolve` is the one route both Run
and the re-run dialog take to `runOptimizer`, and it closes the detent
for the reason `setUiHidden` does — 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.

The Edit/Solver switch goes too, on `uiState.canSwitchBoards`.
`showingSolver` goes on reading `hasSolverPlacements` alone: 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 left the card
reporting the search while the canvas drew 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 was on their own board landed where
nobody was looking. And on a first-ever run the pill appeared partway
through anyway, the moment the first progress report gave it something to
switch to, which on a phone is a line arriving in the HUD unasked.

None of which may move the run pill, and three things in it wanted to.
`.hud-top` takes `justify-content: flex-end` on a compact viewport: the
row is full-width there, so losing the Setup button left one child under
`center` and STOP jumped to the middle of the screen the instant RUN was
pressed, while Setup's `margin-right: auto` takes the free space first
and makes the property a no-op until it leaves. On a wide screen the
toggle shares one centred row with the action pill, so it holds its box
and takes `visibility: hidden` rather than dropping out of the flow —
one declaration for paint, hit testing and the accessibility tree at
once. And `ConfigSidebar` holds its inset for the run instead of clearing
it: Setup is away for seconds and comes back on its own, so handing 190px
to the board and the HUD means taking it again a moment later, with the
pill gliding out from under the cursor that pressed RUN and back when the
layout lands. The teardown still clears for a preview or a hidden
interface, which are states the user is in. It also skips the `0` that
`bind:clientWidth` reports before it has measured, which would 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.
…r them

Singularity Isolation's bonus is x4, not x2.5, and the three Time Lab
researches author fractional bonuses: 0.1 to 1.0 for Absolute Zero and
Infinite Grid, 0.1 to 0.5 for Stellar Forge. The game has eleven
researches; the new one, Quantum Exchange, moves the energy sale price
and is dismissed with the other economy ones.

Every figure measured at the old x2.5 is re-measured at x4. At full
unlocks the neighbourhood cap binds either way, so the Singularity bound
is unchanged. The generator-bound worked example moves to generator7
tier 1, the tier at which the isolation room still binds. The
composition-order examples now use factors the game actually has.

Icons and the atlas are refreshed from a new extraction; the ones that
came back pixel-identical are left alone.
A previewed board's figures were already rated under the anomaly and
research its code names, but nothing on screen said so. The theme keyed
on `configState.hasAnomaly`, the reader's own selection, so a link out
of a Tidal timeline opened in the ordinary navy and one opened by a
reader under Cryo came up purple over a board with no anomaly at all.
`uiState.boardHasAnomaly` answers for the board on screen instead: the
author's anomaly in preview, the player's otherwise.

The readout names them too. A shared board's card grows an Anomaly row
and one row per Time Lab research its code carries, levels clamped the
way `prestigeScales` clamps them, so the row names the level the figures
were actually rated at. A code that says nothing shows nothing.

Re-sharing from preview stamped the reader's rules onto the author's
board. `layoutState.previewRules` hands the code's own rules onward, and
a code that carried none is re-shared carrying none, rather than being
restated as "no anomaly, no research".
`anomaly_select` and `research_set` fire from the configState verbs the
Time Lab's controls call, and neither fires for a press on what is already
chosen, so a timeline is not counted twice. `solve_run` and `solve_done`
carry the rules the run launched under -- the anomaly, and the research as
one `id:level` string numbered from 1 like the tier buttons -- so power and
bound can be split by timeline. One string rather than a param per upgrade,
so one custom dimension covers it and a research added later needs no new
registration.
Reverts d993234. Branch previews on a `gh-pages` site existed so this branch's
board could be looked at before it merged; once it is on `main` there is no
branch left to preview, so the site goes back to one artifact handed to
actions/deploy-pages from `main`, and the cleanup workflow goes with it.

The repository's Pages source has to be switched back to GitHub Actions for
the deploy job to publish, after which the `gh-pages` branch can be deleted.
Reverts 4fa811d, main's copy of the branch-preview deploy. Previews on a
`gh-pages` site existed so this branch's board could be looked at before it
merged; once it is on `main` there is nothing left to preview, so the site
goes back to one artifact handed to actions/deploy-pages from `main`, and the
cleanup workflow goes with it.

The repository's Pages source has to be switched back to GitHub Actions for
the deploy job to publish, after which the `gh-pages` branch can be deleted.
@OGMods
OGMods merged commit a7640fb into main Sep 28, 2026
3 checks passed
@OGMods
OGMods deleted the anomalies branch October 8, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant