Repository navigation
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
AnomalyDefinition(tagged on rule shape) andPRESTIGE_UPGRADES, both applied as uniform scales throughscaleEffectiveBuilding, research first and anomaly second, with waste re-derived at each step.simulateIsland, Cryo's shared pool by handing over the whole board as one island.estimateTotalMaxPowerbounds every rule, includingneighbourHeatCapandisolationRoomfor Singularity. The CLI gains--anomaly.anomaly_selectandresearch_setevents;solve_run/solve_donecarryanomalyandresearch.maindeploys straight to Pages again.Before / after merging
gh-pagesbranch. 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 onmain, then deletegh-pages.anomaly,from,researchandlevelas custom dimensions in GA.Verification
npm run check,npm run knipandnpm test(826 tests) are clean.