diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 9ad28e8..4217a2b 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -77,6 +77,14 @@ union over the **rule shape** (`baseline`, `shared_cooling`, `terrain_affinity`, batch is an existing rule with different numbers, which this makes a table entry — while a genuinely new rule is a variant the solver fails to compile without handling. +**The figures are written once**, as the arguments of one builder per rule in `anomalies.ts`, and +every card string is filled from them. This document names them by field — `coolerMultiplier`, +`multiplier`, `isolated`, `crowded` — rather than restating them. A rebalance keeps the old build +selectable as a `variantOf` entry (Tidal's pre-nerf figure is `tidal_ascendancy_legacy`), because a +save keeps the figure it jumped in under. **Every Tidal measurement below was taken before the nerf, +so it is a measurement of `tidal_ascendancy_legacy`**, and so is every test that pins an exact +Tidal double — that entry's figure can no longer move. + Unlike `BUILDING_TABLE` beside it, the table is **hand-owned**. The game does ship an `*AnomalySO` per anomaly and the external extractor dumps them alongside the roster — but all one of those records carries is an id, four localized terms and the multipliers. Which _rule_ a @@ -110,10 +118,11 @@ Two things about the shapes are load-bearing: and the anomaly in the runtime getter, so what a building is rated at is `(authored × research) × anomaly` — `scaleEffectiveBuilding` called once by `getEffectiveBuildings` as it resolves the roster, and again by whatever applies the anomaly. - Generator7's fourth tier under Infinite Grid level 4 (×1.7) then a crowded ×0.8 is 1.2036e22 - against 1.2036000000000001e22 for ×1.36, and the _order_ is observable too: the same tier under - ×1.25 then a ×1.67 shore is 1.8474375e22 where the reverse is 1.8474374999999997e22. Each call re-derives the waste, so the - last one wins. `tests/scaling.test.ts` pins both readings against the shipped catalogue rather + Generator7's fourth tier under Infinite Grid level 7 (×1.7) then a crowded generator differs in + the last bit from one combined multiply, and the _order_ is observable too: the same tier under + level 3's ×1.3 then a shore bonus differs from the reverse. Each call re-derives the waste, so the last one + wins. `tests/scaling.test.ts` pins both readings, at two fixed factors of its own (`SHORE`, + `CROWDED`), against the shipped catalogue rather than round numbers — every divergence here is in the last bit, where a test on tidy figures passes under either. Nothing above the roster would notice a reversal: the fixtures pass no anomaly, so they cannot see it either. See `docs/game-logic.md`. @@ -137,7 +146,7 @@ tile and every building, so a stage may skip the call entirely. It is hoisted ou common case is one boolean rather than a per-tile array read, and it has to promise the whole of `rate`'s behaviour rather than only its per-tile half — `rate` carries a per-role factor beside its per-tile one (see `shared_cooling` below), and a flag reading `tileScale === null` alone was true -under a pool while every cooler was being scaled by 0.88. A stage taking the invitation would then +under a pool while every cooler was being scaled by its `coolerMultiplier`. A stage taking the invitation would then have rated Cryo coolers at their unscaled figures and returned a layout over-cooled on paper that the game shuts down board-wide, with nothing failing anywhere. So it is false under a tile scale _and_ under a role scale, and true under `baseline` and under `role_isolation` — which `rate` does @@ -173,8 +182,8 @@ fails silently, and in a different way: also what makes it a lookup rather than a multiply. - **The under-fed-reactor move** (`isUnderFed`) measures what a supplier actually sent against `ratedValue`, the capacity the tile was rated for. Against `baseValue` it compares a scaled - delivery with an unscaled ceiling and only calls a reactor under-fed below `1/k` fill — 60% on a - Tidal shore, 66.7% under a maxed Stellar Forge reactor — so the move stops firing on much of what + delivery with an unscaled ceiling and only calls a reactor under-fed below `1/k` fill for a tile rated `k` + — a Tidal shore's `multiplier`, or a maxed Stellar Forge's — so the move stops firing on much of what it exists for. - **The composition retarget's gate** (`targetCanBeat`). A target is scored from the plain roster, which is right — which buildings to use is a counting problem over the roster — but `bestPower` @@ -199,11 +208,11 @@ the same homogeneity reason, and loose in the same place. Loose is the right way a ceiling too low skips a stage that would have helped, one too high costs a few arrangement attempts that fail to beat the layout in hand. -**A terrain bonus is a harder search, not just a bigger number.** A shore generator makes x1.67 the -waste while an inland cooler still covers x1, so a cluster straddling the coast goes offline — a +**A terrain bonus is a harder search, not just a bigger number.** A shore generator makes `multiplier` +times the waste while an inland cooler still covers ×1, so a cluster straddling the coast goes offline — a layout optimised under the base rules scores _lower_ re-rated under Tidal, and the search has to keep each cluster on one side of the shoreline. On Magma Rift at 20s, -`npm run solve -- --map 3 --anomaly tidal_ascendancy` returns 181AC against the baseline's 142AC. +`npm run solve -- --map 3 --anomaly tidal_ascendancy_legacy` returns 181AC against the baseline's 142AC. The seeding heuristics pick candidates by unscaled roster figures, and two ways of changing that were measured: ranking a hub by its fit times the tile's multiplier, and restricting a hub's tiles to one class so it cannot straddle the coast. **Neither paid for itself** — both landed at or below @@ -220,8 +229,8 @@ variants per roster entry rather than a multiply, for the same snap-is-a-string- `rate` is. **At full unlocks this anomaly is close to power-neutral, and that is the rule rather than the -search.** x4 scales a generator's _intake_, which is a capacity: fed by the reactors it already -had, a bonused generator fills to 25% and produces exactly what it did before. Only the penalty +search.** `isolated` scales a generator's _intake_, which is a capacity: fed by the reactors it +already had, a bonused generator fills to `1/isolated` and produces exactly what it did before. Only the penalty bites, and the penalty is avoidable by keeping generators apart. Map 3 at 15s over three seeds comes back 1.420e23 at best under both rules, where the one baseline layout whose generators touch _re-rates_ under the anomaly to 1.182e23 — so the search recovers the whole penalty and there is no @@ -266,18 +275,18 @@ before the terrain case existed, and the generator-bound 3×3 in `island.test.ts a bound that ignores Singularity; both are now the intended side of 100%. **A rule that scales a whole role goes into the roster; one that scales a tile scales the result.** -`roleRatedRoster` rates the roster's coolers ×0.88 under a pool and its generators +`roleRatedRoster` rates the roster's coolers by `coolerMultiplier` under a pool and its generators ×max(isolated, crowded) under a role isolation, and the LP runs on that roster — where the bound is exact in the rule. It used to scale the LP's _result_ by the largest factor instead, which under -Singularity rates reactors and coolers ×4 as well: 3.8× the tight bound on map 1, so a near-optimal +Singularity rates reactors and coolers by `isolated` as well: 3.8× the tight bound on map 1, so a near-optimal layout reads as 25% layout efficiency, and on a generator-bound roster (where the bonus genuinely pays, +88% over the base rules on map 3 at generator7 tier 1) 43% for a layout at 92% of the tight one. Both variants of an isolation are allowed because which one a tile gets is a function of the layout rather than of the island, and a search free to keep generators apart rates every one of -them at the bonus. The pool's ×0.88 lowers the bound only where cooling binds — a roster bound by +them at the bonus. The pool's `coolerMultiplier` lowers the bound only where cooling binds — a roster bound by its direct producers reads the same either way. -**Which is sound but not tight, and the gap is adjacency.** Rating every generator ×4 let the LP +**Which is sound but not tight, and the gap is adjacency.** Rating every generator by `isolated` let the LP buy heat by spending _fewer_ tiles on generators, and past a point no board can deliver it: heat crosses a tile boundary and nothing else, so a generator's intake is the output of the reactors beside it, and the all-or-nothing cooling rule wants coolers beside it too — out of the same eight @@ -292,7 +301,7 @@ that binds. It is homogeneous of degree 1 in the roster exactly as the LP is, so everything at once — a terrain bonus, a research — moves the cap and the generator it caps by the same factor and it goes on not binding; at full unlocks the top generator sits at 72% of it under the base rules. A rule that scales a _single role_ is what it takes, and Singularity is that rule: -×4 puts the top generator at 290% of the cap. Without it the shipped maps' bound sits 6–9% above +the shipped `isolated` puts the top generator at 290% of the cap. Without it the shipped maps' bound sits 6–9% above anything the board allows, reading 83–87% layout efficiency for layouts that are not 83–87% of anything; with it they read 89.4–95.0%, against 93.5–98.5% under the base rules. Nothing else moved — the base, Cryo and Tidal figures on all eight maps are unchanged to the last digit. Under a pool the @@ -338,7 +347,7 @@ of the island as generators and every board has room for that. The residue is geometry the LP cannot see, and it is not small on a generator-bound roster: the bound relaxes reactor-to-generator and cooler-to-producer adjacency away, and under Singularity the layout is _made of_ that adjacency. Gale Hills at generator7 tier 1 typically reads 86% (93% at -best) where the base rules read 91% on the same board. At full unlocks the ×4 buys a generator +best) where the base rules read 91% on the same board. At full unlocks `isolated` buys a generator capacity the layouts were never short of, and the search comes back within about 1% of the base rules' power. @@ -422,7 +431,7 @@ below it produces the board-wide all-or-nothing the rule describes, with no boar anywhere. Each cooler is reported its share of what the pool actually absorbed, which is the game's own reporting rule. -The x0.88 is not part of that. It is a uniform scale on the cooler role, applied through `ctx.rate` +The `coolerMultiplier` is not part of that. It is a uniform scale on the cooler role, applied through `ctx.rate` like any other multiplier, because the game applies it to `CoolerBuilding.CoolingPerSec` — it is what a cooler is worth and what the game shows for it, not a charge levied at the pool. `rate` therefore carries a per-role factor beside its per-tile one; only ever one of the two is in force, @@ -431,7 +440,7 @@ since only one anomaly runs at a time. **The stages that count rather than place read that role factor too**, through `ctx.rateRole`: `solveIsland` rates its four pools by role before anything draws on them, so a hub fit, a composition target and the polish candidates all count a cooler at what the board will hold. They -counted the plain figure once, and under a pool that is exactly ×0.88 too little cooling: every +counted the plain figure once, and under a pool that is exactly the `coolerMultiplier` short on cooling: every target the retarget proposed on maps 3 and 7 came out 8–14% short of the waste it planned to make, which under a pool is a board that is offline _entirely_ — so the stage never produced a layout that could beat the one in hand, silently. With the pools rated, map 7 at 15s over three seeds went @@ -439,7 +448,7 @@ could beat the one in hand, silently. With the pools rated, map 7 at 15s over th pool entry already carrying its role's rating is handed straight back at the write; under every other rule `rateRole` is the identity and the pools are the roster's own objects. -It is worth real power, and most on a fragmented board, since the x0.88 has to be earned back +It is worth real power, and most on a fragmented board, since the `coolerMultiplier` 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. @@ -469,8 +478,8 @@ base rules, and a seed that simulates alive. **Two calibrations were measured under the rules and left as they are.** `hillClimb`'s `moveScale` — the figure the annealing temperature is derived from — is read off the **plain** -roster, so under a rule that rates a tile above it the walk runs colder than intended, up to ×1.67 -under Tidal and ×4 under Singularity. Scaling it by `islandRatingCeiling` was measured over seeds: +roster, so under a rule that rates a tile above it the walk runs colder than intended, up to `multiplier` +under Tidal and `isolated` under Singularity. Scaling it by `islandRatingCeiling` was measured over seeds: Tidal on map 3 at 15s went 176/178/179AC to 179/181/178AC, and Singularity at 30s over five seeds on map 3 142/140/142/142/140AC to 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to 267/271/271/271/274AC — inside run-to-run noise either way. @@ -806,8 +815,8 @@ could not see would restart numbering at 1 and overwrite an untagged run. distinct layouts at the end. Ctrl-C stops it and still prints the report. Cores are spent on two axes and the flags pick which. A **run** farms its islands out to the pool -— the same search either way, since each island still gets its own proportional slice of the -budget, so only wall clock changes. A **session** runs whole attempts in the pool with islands +— the same search either way, since each island still gets its own slice (`islandBudgetsS`) of +the budget, so only wall clock changes. A **session** runs whole attempts in the pool with islands serial inside each; the pool already owns the cores, and a nested pool per island would oversubscribe the machine. diff --git a/docs/game-logic.md b/docs/game-logic.md index 4e7656e..bd6367b 100755 --- a/docs/game-logic.md +++ b/docs/game-logic.md @@ -401,11 +401,11 @@ other eight move research income, research time, Chronons, obstacle-removal cost, energy sale price and building prices — all decided before a board is laid out, so none can change which layout is best. -| research | scales | levels (×) | -| ------------------ | --------------------------------------------- | ----------- | -| **Stellar Forge** | every heat producer's heat output | ×1.1 – ×1.5 | -| **Infinite Grid** | every generator's and wind turbine's heat intake, energy and overheat capacity | ×1.1 – ×2.0 | -| **Absolute Zero** | every cooler's cooling | ×1.1 – ×2.0 | +| research | scales | levels (×) | +| ----------------- | ------------------------------------------------------------------------------ | ----------- | +| **Stellar Forge** | every heat producer's heat output | ×1.1 – ×1.5 | +| **Infinite Grid** | every generator's and wind turbine's heat intake, energy and overheat capacity | ×1.1 – ×2.0 | +| **Absolute Zero** | every cooler's cooling | ×1.1 – ×2.0 | Absolute Zero and Infinite Grid share one bonus curve — 0.1, 0.25, 0.45, 0.7, 1.0 — and Stellar Forge a shallower one — 0.1, 0.2, 0.3, 0.4, 0.5. The game @@ -415,7 +415,7 @@ authors them as a bonus fraction and shows them as "+10%" through "+100%" (or **The roles do not overlap, and the boundary is the class of building rather than the catalogue's grouping.** The game's catalogue files reactors and wind turbines together under one "heat producer" category, but a wind turbine is a -*power source* in the code, so Stellar Forge does not touch it — Infinite Grid +_power source_ in the code, so Stellar Forge does not touch it — Infinite Grid does, which is why that one goes out of its way to name wind turbines. A reactor is the only heat producer the shipped roster has, so in practice Stellar Forge is "reactors"; it would cover any other heat producer the game added. @@ -436,6 +436,14 @@ An anomaly never adds a building, removes one, or changes the grid. It changes either **what a placed building's figures are** or **how a supply gets shared out**, and the three that exist so far divide cleanly along that line. +**This document does not restate the figures.** Each anomaly's numbers are +written once, in `packages/solver/src/data/anomalies.ts`, and named here by the +field that holds them: Cryo Nexus's `coolerMultiplier`, Tidal Ascendancy's +`multiplier`, Singularity Isolation's `isolated` and `crowded`. The game's own +text is quoted below with those names in place of its `{0}` placeholders, which +is how the table fills them. The game rebalances these; a copy here is a copy +that goes stale. + ### What a multiplier actually multiplies This applies to Time Lab research just as much as to an anomaly — the two enter @@ -449,13 +457,13 @@ four roles: | the game says | the figure | | ----------------- | ------------------------------------------------------------------------------- | -| Heat | a reactor's output, a generator's intake capacity, a direct producer's own heat | -| Energy | what a generator or direct producer puts out at full | -| Cooling | what a cooler absorbs | -| overheat capacity | the size of a power source's waste-heat store — not the waste it makes | +| Heat | a reactor's output, a generator's intake capacity, a direct producer's own heat | +| Energy | what a generator or direct producer puts out at full | +| Cooling | what a cooler absorbs | +| overheat capacity | the size of a power source's waste-heat store — not the waste it makes | -So a building with a ×1.67 bonus is simply a building whose whole tier is worth -1.67× as much: it absorbs more, produces more, and needs proportionally more +So a building with a ×k bonus is simply a building whose whole tier is worth k +times as much: it absorbs more, produces more, and needs proportionally more cooling. A bonus is **not** free power on its own — it moves the whole balance of a layout, and a scaled producer that outruns its coolers stops running like any other. @@ -485,7 +493,7 @@ waste = snapToAuthoredPrecision(heat − energy) ← again, from the new Scaling the authored waste directly instead — `authoredWaste × timeLab × anomaly` — is wrong. It agrees to about fifteen digits and disagrees in the -last, because the snap is a decimal rounding and it is applied to the *scaled* +last, because the snap is a decimal rounding and it is applied to the _scaled_ difference, not carried along from the table. The same is true of carrying a once-derived waste through the second multiply: the last derivation wins. @@ -494,20 +502,20 @@ as raw products, because a multiplier is a runtime multiply rather than an authored table entry; the derived waste is the only exception, and it is re-derived wherever the pair it comes from is scaled again. -**Anomaly and research compose multiplicatively.** A generator under Singularity -Isolation (×4) with Infinite Grid maxed (×2) is rated ×8. That is a -*rating*, not a promise of 8× the power: what it actually produces still -depends on how much heat reaches it. +**Anomaly and research compose multiplicatively.** An isolated generator under +Singularity Isolation with Infinite Grid maxed (×2) is rated `isolated` × 2. +That is a _rating_, not a promise of that much more power: what it actually +produces still depends on how much heat reaches it. It is also not always the same double as one combined multiply, and the -difference is real rather than pedantic — though not at this pair, since ×2 and -×4 are powers of two and scale exactly. Infinite Grid at level 4 (×1.7) on a -crowded generator (×0.8) rates generator7's fourth tier at 1.2036e22 as two -successive scalings and 1.2036000000000001e22 as one ×1.36. The **order** -matters for the same reason — the same tier under Infinite Grid at level 2 -(×1.25) then a ×1.67 shore bonus is 1.8474375e22 where the reverse is -1.8474374999999997e22 — so research first, anomaly second, exactly as the two -getters run. +difference is real rather than pedantic. `tests/scaling.test.ts` pins it with two +fixed factors of its own, `SHORE` and `CROWDED` — literals there, because the +bits belong to those numbers: Infinite Grid at level 7 (×1.7) on a `CROWDED` +generator rates generator7's fourth tier one way as two successive scalings and +one ULP off as a single combined multiply. The **order** matters for the same +reason — the same tier under Infinite Grid at level 3 (×1.3) then a `SHORE` +tile differs in the last bit from the reverse — so research first, anomaly +second, exactly as the two getters run. And **overheat capacity scaling changes nothing for a sustainable layout**, since the store it sizes never fills. It is listed because the game lists it. @@ -517,8 +525,8 @@ since the store it sizes never fills. It is listed because the game lists it. > All Heat Sinks on an island add their Cooling to one shared pool. It can cool > every Power Source on that island, no matter how far away it is. If there is > not enough Cooling, every Power Source receives the same percentage of what it -> needs. Each Heat Sink contributes ×0.88 of its normal Cooling. Cooling does -> not carry over to other islands. +> needs. Each Heat Sink contributes ×`coolerMultiplier` of its normal Cooling. +> Cooling does not carry over to other islands. This replaces the cooling half of the distribution wholesale. Adjacency stops mattering for cooling, and so do the fair split and the cleanup pass — there is @@ -532,7 +540,7 @@ stored; and if the pool falls short, everyone is served the same fraction of what they asked for. **Wind turbines draw from it too** — they are power sources like any other, even though they take no part in heat delivery. -The ×0.88 applies to each cooler's own cooling rating, so it is what the game +The `coolerMultiplier` applies to each cooler's own cooling rating, so it is what the game shows on that cooler, not a separate charge levied at the pool. Two consequences fall straight out of it, and both matter more than the rule @@ -558,7 +566,7 @@ itself: **"An island" here means the whole map.** Gale Hills is an island; so is Ash Bay. The rule is board-wide: every cooler anywhere on the map pays into one pool, and every power source anywhere on the map draws from it. "Cooling does -not carry over to other islands" means it does not carry to a *different map* — +not carry over to other islands" means it does not carry to a _different map_ — there is nothing finer than the board for this pool to respect. This is the one rule in the game that reaches across the whole board, and every @@ -566,7 +574,7 @@ other rule in this document is the opposite: adjacency-bound, which is why a board splits into independent patches of connected grass and each can be solved on its own. Under this anomaly they are not independent. -They are still *nearly* independent, and the shape of what survives is worth +They are still _nearly_ independent, and the shape of what survives is worth being precise about, because it is what decides how hard this is to solve: - **Heat is untouched.** Reactors still only reach adjacent generators, so which @@ -574,7 +582,7 @@ being precise about, because it is what decides how hard this is to solve: question about that patch alone. - **The patches are coupled by exactly two numbers.** A layout on a patch makes some power, generates some waste, and contributes some cooling; the board runs - on whether the cooling *summed over every patch* covers the waste summed over + on whether the cooling _summed over every patch_ covers the waste summed over every patch. Nothing else crosses. So a patch is no longer described by "the most power it can make" but by a @@ -607,9 +615,16 @@ they are unbuildable ground. ### Tidal Ascendancy — a bonus for building on the shore -> Production buildings next to water get a ×1.67 multiplier. Corners count too. -> The bonus affects Energy, Heat, Cooling, and overheat capacity. Buildings away -> from water work normally and get no bonus. +> Production buildings next to water get a ×`multiplier` multiplier. Corners +> count too. The bonus affects Energy, Heat, Cooling, and overheat capacity. +> Buildings away from water work normally and get no bonus. + +**The game has nerfed the multiplier once.** A timeline keeps the figure it +jumped in under until it jumps again, so a save from before the nerf still runs +at the old one: the table carries both, `tidal_ascendancy` at the live figure +and `tidal_ascendancy_legacy` at the pre-nerf one, and the Tidal card switches +between them. Tests that pin an exact double use the legacy entry, whose figure +can no longer change. A per-tile uniform scale, decided by terrain alone: a building is scaled if any of the eight tiles around it is water. Nothing about the layout can change which @@ -619,9 +634,10 @@ per island and then treat as part of the board. It is the first rule in the game where a non-buildable tile does anything at all. It covers **every** role, not only the ones "production building" sounds like: a -cooler beside water absorbs ×1.67, a reactor beside water puts out ×1.67, and a -generator or wind turbine beside water has all three of its figures scaled. And -one water neighbour is worth the same as eight — the bonus does not stack. +cooler beside water absorbs ×`multiplier`, a reactor beside water puts out +×`multiplier`, and a generator or wind turbine beside water has all three of its +figures scaled. And one water neighbour is worth the same as eight — the bonus +does not stack. **A pond is not water.** It is an obstacle — scenery you cannot build on, like a rock or a tree — and it grants no shore bonus. Only the water tile proper does. @@ -640,17 +656,17 @@ perimeter is shore and its 64-tile interior is not. The bonus is not a rare one: between 36% and 45% of the grass on every shipped map is water-adjacent, so on any of them something close to half the buildable -board is worth ×1.67. That makes the shoreline the most valuable ground on the +board is worth ×`multiplier`. That makes the shoreline the most valuable ground on the map and this anomaly a genuine change of shape rather than a modifier, since the best layout under it wants its producers on a coastline whose length is fixed by the terrain. ### Singularity Isolation — generators want elbow room -> A Generator with no other Generator next to it gets ×4 Energy output, Heat -> output, and overheat capacity. If another Generator touches it, including at a -> corner, those values drop to ×0.8. More neighbours do not make the penalty -> worse. +> A Generator with no other Generator next to it gets ×`isolated` Energy +> output, Heat output, and overheat capacity. If another Generator touches it, +> including at a corner, those values drop to ×`crowded`. More neighbours do not +> make the penalty worse. Unlike the other two, this one depends on **the layout itself**: a generator's figures are a function of what its neighbours are, so they change as the search diff --git a/package.json b/package.json index 7309fcf..1fd1cca 100755 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "test:watch": "vitest", "build:solver": "npm run build --workspace @reactor2/solver", "solve": "npm run solve --workspace @reactor2/solver --", - "fixtures": "npm run fixtures --workspace @reactor2/solver --" + "fixtures": "npm run fixtures --workspace @reactor2/solver --", + "survey": "npm run survey --workspace @reactor2/solver --" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.2.0", diff --git a/packages/solver/CLAUDE.md b/packages/solver/CLAUDE.md index 22bb5c2..3c949f6 100644 --- a/packages/solver/CLAUDE.md +++ b/packages/solver/CLAUDE.md @@ -16,7 +16,7 @@ researches: Absolute Zero (cooler cooling), Infinite Grid (generator and wind turbine stats) and Stellar Forge (reactor heat). The other eight move research income, research time, chronons, obstacle-removal, energy sale price and building prices — all -decided *before* the solver is handed a board, so none can change which layout +decided _before_ the solver is handed a board, so none can change which layout is best. The file names them so it is clear they were read and dismissed. Three things bind: @@ -27,7 +27,7 @@ Three things bind: is re-derived as `snap(heat - energy)` from the pair that was just scaled, so a generator rated ×2 makes ×2 the waste and needs ×2 the cooling to stay online. The overheat threshold Infinite Grid also names is **not** the reason - — it sizes a waste-heat *store* rather than the waste itself, and a + — it sizes a waste-heat _store_ rather than the waste itself, and a sustainable layout never fills it, so per `docs/game-logic.md` it is never the binding constraint and `EffectiveBuilding` does not model it at all. - **It folds into the roster, not into the solver.** `prestigeScales()` resolves @@ -47,12 +47,13 @@ Three things bind: `effectiveAtValue` takes it too, and must: a placement stores the **authored** tier value, which identifies the tier and is deliberately never rewritten, so the research is applied on the way out every time. + - **A multiplier breaks what `effectiveValue` used to mean, so `EffectiveBuilding` carries `baseValue` beside it** — the authored tier value, which every scaling helper passes through **untouched**. `simulateIsland` reports that, never `effectiveValue`, because a placement's tier is resolved back out of the reported number by matching the catalogue: report a scaled one - and it matches a *higher* tier and gets scaled a second time. A generator at + and it matches a _higher_ tier and gets scaled a second time. A generator at authored 320 under a x2 research reported 640, resolved as the authored 640 tier, and came back 1280 at one level too high — in the readout's ceilings, the `Lv.` chip, `copySolveToBoard` and the tier table of every share code, with @@ -71,7 +72,7 @@ Three things bind: button's tooltip. **Stellar Forge does not cover wind turbines.** The game's `heat_producer` -category holds reactors *and* direct producers, so "all Heat Producers" read +category holds reactors _and_ direct producers, so "all Heat Producers" read literally would take the turbine — but a turbine's SO inherits `PowerSourceBuildingSO` and reads Infinite Grid, while Stellar Forge is read only by `HeatProducerBuildingSO`. That is why Infinite Grid goes out of its way to @@ -89,6 +90,14 @@ timeline. `docs/game-logic.md` is the authority on what each one does and **hand-owned** — unlike `BUILDING_TABLE` beside it, what the extractor can read is the text and the multipliers, never the rule shape those numbers plug into. +- **The figures are written once**, as the arguments of one builder per rule + in `data/anomalies.ts`, and every card string is filled from them. Docs, + comments and tests name them by field — `coolerMultiplier`, `multiplier`, + `isolated`, `crowded` — and tests read them through `anomalyOfRule` in + `tests/helpers.ts`. A rebalance keeps the old build as a `variantOf` entry + (Tidal's pre-nerf figure is `tidal_ascendancy_legacy`), since a save keeps the + figure it jumped in under; every Tidal measurement in these docs, and every + test pinning an exact Tidal double, is of that frozen entry. - `"none"` is an entry, not an absence, and `getAnomaly` is total: an id it does not know resolves to the baseline, because the id arrives off `localStorage` and the worker boundary. @@ -98,20 +107,21 @@ timeline. `docs/game-logic.md` is the authority on what each one does and `scaleEffectiveBuilding` recomputes `snapToAuthoredPrecision(heat - energy)` from the pair it just scaled, because that is the game's runtime getter and carrying the authored waste through the same multiply disagrees in the last - digit, where the fixtures assert exactly. Only the two roles that *have* waste + digit, where the fixtures assert exactly. Only the two roles that _have_ waste derive one — `heat - energy` over a cooler or a reactor would turn its whole output into waste. **Research and anomaly arrive as two successive calls, in that order**, which is how the game applies them (the Time Lab in the SO getter, the anomaly in the runtime getter) and is not the same double as one combined factor: - generator7's fourth tier under Infinite Grid level 4 (x1.7) then a crowded - x0.8 is 1.2036e22, against 1.2036000000000001e22 for x1.36, and the order - shows too — under x1.25 then a x1.67 shore it is 1.8474375e22, against - 1.8474374999999997e22 the other way round. `scaling.test.ts` pins both, - against the shipped catalogue rather than round numbers — every divergence + generator7's fourth tier under Infinite Grid level 7 (x1.7) then a crowded + penalty differs in the last bit from one combined factor, and the order + shows too — level 3's x1.3 then a shore bonus differs from the reverse. + `scaling.test.ts` pins both, at two fixed factors of its own (`SHORE`, + `CROWDED`) and against the shipped catalogue rather than round numbers — every divergence here is in the last bit, so a test on tidy figures passes under either reading. + - **Cryo Nexus pools across the whole board, so under it the board is one island.** The game's "island" is the map, so the components `splitGridIntoIslands` produces **do** interact — against the assumption the @@ -137,17 +147,17 @@ timeline. `docs/game-logic.md` is the authority on what each one does and **The pool itself is in `simulateIsland` and needs no second code path.** It serves every source the same fraction of what it is owed, so a short pool - leaves *all* of them under their waste and the ordinary per-producer online + leaves _all_ of them under their waste and the ordinary per-producer online test turns that into the board-wide all-or-nothing the rule describes. The - x0.88 is a uniform scale on the cooler role, applied through `ctx.rate` like + `coolerMultiplier` is a uniform scale on the cooler role, applied through `ctx.rate` like any other multiplier, because the game applies it to `CoolerBuilding. - CoolingPerSec` — it is what a cooler is worth and what the game shows for it, +CoolingPerSec` — it is what a cooler is worth and what the game shows for it, not a charge levied at the pool. It is worth real power, and most on a fragmented board: map 7 at 25s goes 271AC to 294AC, map 3 at 20s 139AC to 145AC, and map 1 — one landmass, already - at 98% of its bound — is unchanged. The x0.88 has to be earned back before any - of that shows. + at 98% of its bound — is unchanged. The `coolerMultiplier` has to be earned + back before any of that shows. **The seed is the one stage that branches on the rule.** Under a pool `constructSeed` builds every hub engine-first — a phantom cooler sizes the @@ -167,8 +177,24 @@ timeline. `docs/game-logic.md` is the authority on what each one does and every producer and the upgrade plan all hold, so the pass cannot cost a watt. It is capped at 150ms for the primary and 150ms shared across the alternates; on the shipped maps one layout takes 15-110ms. Map 1 goes from - five cooler clusters to one. Coolers on a second working landmass stay there - when no empty tile near the anchor is free to take them. + five cooler clusters to one. + + **Before the sweep, hubs leave the smaller landmasses** + (`evacuateMinorLandmasses`). One tile at a time cannot move a hub: a + generator taken from its reactors loses power at every step, so a hub on a + second landmass kept its coolers there. Each smaller landmass that holds + producers, smallest first, has all of them moved onto the landmass holding + the most. A greedy placement puts them on that landmass's cooler or empty + tiles, and each displaced cooler crosses to the tile its producer left, so + composition and pool are unchanged. A swap descent over the main landmass + then restores power. The move is kept only on the same three tests as a swap, + and the emptied landmass then fills with coolers like any scrap. It has its + own 1s cap on top of the sweep's (`EVACUATE_BUDGET_MS`), sized for a phone; a + 15-producer hub takes about 170ms on a desktop. On the eight maps with + obstacles cleared it brings seven to a single producing landmass at the same + power. Shadowspire keeps two, because its 29-producer second landmass does + not fit in the main one's coolers. + - **Off the board counts as water**, which is a fact about our data rather than about the game: the game has one global map with open water between islands, and our eight boards are rectangles cut out of it, so the water past an edge is @@ -180,13 +206,14 @@ timeline. `docs/game-logic.md` is the authority on what each one does and tell an off-board neighbour from the window's own edge — which is why `computeWaterAdjacency` runs on the full grid before the split and each sub-grid carries a `waterAdjacent` mask indexed like `buildable`. + - **`ANOMALIES` is transcribed by hand from the same extractor's output**, which emits an anomaly record alongside the roster and drops the icons into `public/icons/anomaly_.webp`. Unlike `BUILDING_TABLE`, **nothing splices this table** — the extractor writes its record and stops — so a new anomaly is - brought across by hand. The game's `{0}`-templated strings land here with their - `values` resolved, so the wording and the numbers the solver runs on cannot - disagree. + brought across by hand. The game's `{0}`-templated strings land in a builder + per rule that fills them from the same arguments the rule fields take, so the + wording and the numbers the solver runs on cannot disagree. **What catches a half-finished job is the codec's byte maps.** They are keyed on the closed id unions — `Record` and `PrestigeUpgradeId`, @@ -200,6 +227,7 @@ timeline. `docs/game-logic.md` is the authority on what each one does and is not an anomaly id at all, arriving off `localStorage` or the worker boundary — which is the same total reading `getAnomaly` makes. Decode stays tolerant: an unknown byte reads as no anomaly. + - **All four entries are implemented**, `none` being the one with nothing to do — it is an entry rather than an absence. The anomaly is threaded the whole way — `SolveOptions.anomalyId` / `SolveRunOptions.anomalyId`, the worker request, @@ -209,18 +237,18 @@ timeline. `docs/game-logic.md` is the authority on what each one does and decomposition, before there is an island at all. **Singularity is resolved per layout, in `simulateIsland`, and it has to be.** - A generator's rating is a function of what its neighbours *are*, so placing + A generator's rating is a function of what its neighbours _are_, so placing one re-rates up to eight other tiles — it cannot be folded into a tile like a terrain bonus, and it cannot be settled at the write. `simulateIsland` copies the layout into `ctx.ratedLayout` (held by the island, not allocated per call), re-rates the affected role's tiles, and reads its figures through that. - The neighbour scan reads the *original* placement, since the test is on what a + The neighbour scan reads the _original_ placement, since the test is on what a neighbour is and no rating changes that — so there is no order to get right. **The bonus is on a capacity, which is why the rule is close to power-neutral - at full unlocks.** x4 scales a generator's *intake*, and a generator fed by - the reactors it already had simply fills to 25% and produces exactly what it - did before; the bonus is worth something only alongside more adjacent reactor + at full unlocks.** `isolated` scales a generator's _intake_, and a generator + fed by the reactors it already had simply fills to `1/isolated` and produces + exactly what it did before; the bonus is worth something only alongside more adjacent reactor heat. The penalty is real and avoidable, so the search's job under this anomaly is mostly to keep generators apart. Map 3 at 15s over three seeds: 1.420e23 at best under both, where the one baseline layout whose generators @@ -229,7 +257,7 @@ timeline. `docs/game-logic.md` is the authority on what each one does and way. **On a generator-bound roster it is not neutral, and the count is what the - search gets wrong.** `targetCompositions` is the one stage that decides *what* + search gets wrong.** `targetCompositions` is the one stage that decides _what_ to build, and how many generators are worth their tile depends on what a generator is worth — two numbers under this rule, neither of them in the pool the stage draws on (`rateRole` is the identity here, since which one a tile @@ -255,7 +283,7 @@ timeline. `docs/game-logic.md` is the authority on what each one does and every building — so a stage may skip the call entirely, and it therefore has to promise the whole of `rate` rather than only its per-tile half: `rate` carries a per-role factor beside its per-tile one, so the flag is false under - a terrain bonus *and* under a cooling pool's ×0.88, and true under `none` and + a terrain bonus _and_ under a cooling pool's `coolerMultiplier`, and true under `none` and under Singularity, which `rate` does not answer at all. It is called where a building is **written onto a tile**, never where its @@ -290,12 +318,12 @@ timeline. `docs/game-logic.md` is the authority on what each one does and from 1 of 3 targets attempted to 3 of 3 with the power difference inside run-to-run noise, so it buys back a stage rather than a number. - **A bonus is genuinely not free power here.** A shore generator makes x1.67 - the waste while an inland cooler still covers x1, so a cluster straddling the + **A bonus is genuinely not free power here.** A shore generator makes + `multiplier` times the waste while an inland cooler still covers x1, so a cluster straddling the shoreline goes offline — a layout optimised under the base rules scores - *lower* re-rated under Tidal. The search has to keep a cluster on one side of + _lower_ re-rated under Tidal. The search has to keep a cluster on one side of the coast, which is a harder problem than the uniform one. On Magma Rift at - 20s, `--anomaly tidal_ascendancy` returns 181AC against the baseline's 142AC. + 20s, `--anomaly tidal_ascendancy_legacy` returns 181AC against the baseline's 142AC. The seeding heuristics pick candidates by unscaled roster figures. Two ways of making them tile-aware were measured and **neither paid for itself**: ranking a candidate hub by its fit times the tile's multiplier, and restricting a @@ -312,11 +340,11 @@ timeline. `docs/game-logic.md` is the authority on what each one does and layout efficiency before the terrain case existed, and the generator-bound 3x3 in `island.test.ts` reads 267% against a bound that ignores Singularity. A rule that scales a whole role goes **into the roster** the bound is run on - (`roleRatedRoster`: coolers ×0.88 under a pool, generators ×4 under - Singularity), where the bound is exact in it; scaling the LP's result by the + (`roleRatedRoster`: coolers by `coolerMultiplier` under a pool, generators + by `isolated` under Singularity), where the bound is exact in it; scaling the LP's result by the largest factor instead rates reactors and coolers up too and would leave the Singularity bound 3.8x the tight one, so a near-optimal layout reads 25%. A - rule that scales a *tile* splits the island's tile budget by class + rule that scales a _tile_ splits the island's tile budget by class (`estimateIslandBound`, `estimateMixedIslandMaxPower`): a shore building is worth the shore-scaled roster, an inland one the plain roster, and both pay into the one heat and the one cooling total, since the bound relaxes @@ -339,10 +367,10 @@ timeline. `docs/game-logic.md` is the authority on what each one does and whatever the reactors beside it make, and the all-or-nothing cooling rule wants coolers beside it too — out of the same eight tiles. So `estimateIslandMaxPower` runs on `min(gVal, 8 / (1 / rVal + wasteRatio / - cVal))`, and the cooler term goes under a pool, where cooling reaches the +cVal))`, and the cooler term goes under a pool, where cooling reaches the whole island. The cap is homogeneous like the LP, so a rule that scales everything at once can never make it bite; a rule that scales **one role** - is what it takes, and Singularity's ×4 puts the top generator at 290% of + is what it takes, and Singularity's shipped `isolated` puts the top generator at 290% of it — without the cap the bound sits 6-9% above anything the board allows, reading 83-87% efficiency for layouts that are not 83-87% of anything. With it the maps read 89.4-95.0%, and the base, Cryo and Tidal figures do not move @@ -380,13 +408,15 @@ they are bought with Chronons, survive a Time Jump, and stack with whatever anomaly is running — **multiplicatively**, so a generator under Singularity Isolation with Infinite Grid maxed is rated x8. The extractor's record covers all eleven Time Lab upgrades; the three a layout can see are **Stellar Forge** -(every heat producer, so reactors), **Infinite Grid** (generators *and* wind -turbines), and **Absolute Zero** (every cooler), each five levels of -`BonusPercentage`. Absolute Zero and Infinite Grid share one curve, 0.1 to 1.0; -Stellar Forge a shallower one, 0.1 to 0.5. That field is a fraction rather than +(every heat producer, so reactors), **Infinite Grid** (generators _and_ wind +turbines), and **Absolute Zero** (every cooler), each ten levels of +`BonusPercentage`. Absolute Zero and Infinite Grid share one curve, 0.1 to 1.0 +in steps of 0.1; Stellar Forge a shallower one, 0.05 to 0.5 in steps of 0.05. +(Until the October 2026 game update each had five, on uneven steps to the same +ceilings.) That field is a fraction rather than a percent -- the same field is 0.05 on Chronon Reactor, which the game shows as +5% -- so the levels are worth x1.1 to x2 (Absolute Zero, Infinite Grid) and -x1.1 to x1.5 (Stellar Forge). Like an anomaly's, the scale is uniform, so the +x1.05 to x1.5 (Stellar Forge). Like an anomaly's, the scale is uniform, so the cooling a boosted producer needs grows with it. Their badges ship as `public/icons/prestige_.webp`. @@ -405,7 +435,7 @@ grid is an awaited step rather than something a constructor can do. input. Use `blueprintKey()`, which compares the uncompressed payload. - **A code says which format it is, and an old one is recognised by its width.** `BLUEPRINT_VERSION` is byte 0 of every new code; codes written before - it began with the width instead, so the two are told apart by *value* — a + it began with the width instead, so the two are told apart by _value_ — a board is at least `MIN_GRID_DIM` (5) on a side, so a first byte below that cannot be a width. Which is why that constant now lives in the codec rather than in the size stepper that enforces it, and why lowering it would not @@ -684,3 +714,18 @@ but stage deadlines are wall-clock, so seeding narrows run-to-run variance rather than eliminating it. For a whole-board check use the CLI (`npm run solve -- --map 1 --attempts 10 --time 20`): the search is stochastic and map 1 swings ~2% run to run, so compare several runs, not one. + +**For a change to what a real run finds, use the survey before and after.** It +solves every map (obstacles cleared) under every anomaly, island by island: + +```bash +npm run survey -- --runs 5 --json solves/base.json # on the old code +npm run survey -- --runs 5 --compare solves/base.json # on the new +``` + +`--compare` judges each island on the mean of its runs, against a noise band +the width of the wider side's run spread, and exits 1 if any island fell below +it. Same `--time`, `--runs` and `--seed` on both sides, same machine, same load — +it warns if the first three differ. Expect about ±0.8% per map at 15s × 5 runs, +so a gain smaller than that needs more runs to show; "no island got worse" is +the claim it can make at any size. diff --git a/packages/solver/bin/solve.ts b/packages/solver/bin/solve.ts index 04f47e7..e577ed6 100644 --- a/packages/solver/bin/solve.ts +++ b/packages/solver/bin/solve.ts @@ -18,7 +18,7 @@ * Cores are spent on two axes, and which one depends on the flags: * * - **A run** (`--runs`, or no flags) farms its islands out to a worker pool. - * Each island still gets its own proportional slice of the budget, so this + * Each island still gets its own slice of the budget, so this * changes wall clock and not the search. On the shipped maps it buys little — * most are one large landmass plus a few 2-3 tile scraps, so one island holds * ~95% of the budget. @@ -143,7 +143,7 @@ function offsetSeed( * One solve, with the islands farmed out to `pool` when there is more than one. * * Islands never interact, so this is the same search either way — each island - * still gets its own proportional slice of the budget. Only the wall clock + * still gets its own slice of the budget (`islandBudgetsS`). Only the wall clock * changes: concurrently it is ~max(slice) instead of the whole budget. */ async function solveWithPool( diff --git a/packages/solver/package.json b/packages/solver/package.json index 677762c..1242ebc 100644 --- a/packages/solver/package.json +++ b/packages/solver/package.json @@ -37,6 +37,7 @@ "build": "vite build -c vite.build.config.ts && tsc -p tsconfig.build.json", "solve": "tsx bin/solve.ts", "fixtures": "tsx scripts/exportFixtures.ts", + "survey": "tsx scripts/gapSurvey.ts", "prepublishOnly": "npm run build" } } diff --git a/packages/solver/scripts/gapSurvey.ts b/packages/solver/scripts/gapSurvey.ts new file mode 100644 index 0000000..91f809e --- /dev/null +++ b/packages/solver/scripts/gapSurvey.ts @@ -0,0 +1,642 @@ +/** + * Surveys how far the search lands from the upper bound, island by island. + * + * npm run survey (from the repo root) + * npm run survey -- --time 30 --runs 5 + * npm run survey -- --map 3 --map 7 --anomaly cryo_nexus + * npm run survey -- --json solves/survey.json + * npm run survey -- --compare solves/base.json + * + * Every shipped map is solved under every anomaly, `--runs` times each, and + * every island's best is set against its own share of the bound. The report + * ranks the islands by how much of their map's bound they leave on the table, + * which is where search work would pay off — or where the bound is loose, which + * this cannot tell apart on its own. The run spread is the hint: an island + * whose runs disagree is short of time or luck, one that lands on the same + * figure every run is either stuck in one basin or already at the true optimum + * of a bound that is not tight. + * + * Islands are solved one task each through the CLI's own worker pool, with + * the slice of `--time` `planSolve` gives them — the same budget a real run + * spends on that island — so a figure here is a figure the app would get. + * + * **Every map is surveyed with its obstacles cleared.** Rocks, trees and ponds + * are removable in the game and become grass here; water and the transformer + * stay. Obstacles are scenery the player buys away, so the open board is the + * one whose gap says something about the search rather than about the clutter. + * + * **`--compare` is the before-and-after check for an engine change.** Save a + * baseline with `--json` on the old code, then run the same survey with + * `--compare` on the new: every island is set against its baseline and the run + * exits 1 if any got worse. The golden fixtures replay the deterministic core + * of the search with a step cap, so they cannot see what a change does to a + * real wall-clock run; this can. Compare like with like — same `--time`, + * `--runs` and `--seed`, on the same machine at the same load, since every + * stage of the search is budgeted in wall-clock time. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { WorkerPool } from "../bin/pool"; +import { MAPS, MAPS_BY_NUM, type CliMap } from "../bin/maps"; +import { ANOMALIES, getAnomaly } from "../src/data/anomalies"; +import { BUILDINGS, allUpgradesUnlocked } from "../src/data/buildings"; +import { decodeBlueprint } from "../src/encoding/blueprint"; +import { estimateTotalMaxPower } from "../src/solver/island"; +import { planSolve, type IslandPlan } from "../src/solver/solver"; +import type { + AnomalyId, + IslandLayout, + Tile, + TileType, +} from "../src/solver/types"; +import { formatNumber } from "../src/utils/formatters"; + +const WORKER_URL = new URL("../bin/worker.ts", import.meta.url); + +/** The removable scenery. A pond is one of them, not water: see `docs/game-logic.md`. */ +const OBSTACLES: ReadonlySet = new Set([ + "rock", + "tree1", + "tree2", + "pond", +]); + +function clearObstacles(grid: Tile[][]): Tile[][] { + return grid.map((row) => + row.map((tile) => + OBSTACLES.has(tile.type) ? { ...tile, type: "grass" } : tile, + ), + ); +} + +const DEFAULT_TIME_S = 15; +const DEFAULT_RUNS = 3; +const DEFAULT_TOP = 15; +const SEED_STRIDE = 100003; + +/** At or above this share of its bound an island counts as solved. */ +const AT_BOUND = 0.9995; + +interface Args { + maps: number[]; + anomalies: AnomalyId[]; + timeS: number; + runs: number; + top: number; + workers: number; + seed: number; + json: string | undefined; + compare: string | undefined; +} + +interface IslandRow { + map: CliMap; + anomalyId: AnomalyId; + index: number; + /** Top-left buildable tile on the full board, so an island can be found. */ + x: number; + y: number; + tiles: number; + budgetS: number; + bound: number; + powers: number[]; +} + +interface Case { + map: CliMap; + anomalyId: AnomalyId; + plan: IslandPlan; + rows: IslandRow[]; +} + +const USAGE = `usage: survey [--map NUM]... [--anomaly ID]... [--time S] [--runs N] + [--top K] [--workers N] [--seed SEED] [--json PATH] + [--compare PATH] + +Solve every map under every anomaly and report each island's gap to its bound. + +options: + --map NUM map to survey (repeatable; default: every map but the blank one) + --anomaly ID anomaly to survey (repeatable; default: every current one, + so a pre-nerf variant runs only when named here). + One of: ${ANOMALIES.map((a) => a.id).join(", ")} + --time S per-map budget, split across islands as a real run splits it + (default: ${DEFAULT_TIME_S}) + --runs N solves per map and anomaly; each island keeps its best (default: ${DEFAULT_RUNS}) + --top K how many islands the ranking lists (default: ${DEFAULT_TOP}) + --workers N islands solved at once (default: every core, ${os.cpus().length} here) + --seed SEED base seed (default: 1) + --json PATH also write every island's figures to PATH + --compare PATH set every island against a baseline written by --json, and + exit 1 if any got worse than run-to-run noise explains`; + +function parseArgs(argv: string[]): Args | null { + const args: Args = { + maps: [], + anomalies: [], + timeS: DEFAULT_TIME_S, + runs: DEFAULT_RUNS, + top: DEFAULT_TOP, + workers: os.cpus().length, + seed: 1, + json: undefined, + compare: undefined, + }; + + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag === "-h" || flag === "--help") { + console.log(USAGE); + return null; + } + const raw = argv[++i]; + if (raw === undefined) throw new Error(`${flag} expects a value`); + const num = (): number => { + const n = Number(raw); + if (!Number.isFinite(n)) + throw new Error(`${flag}: '${raw}' is not a number`); + return n; + }; + + switch (flag) { + case "--map": + if (!MAPS_BY_NUM.has(num())) throw new Error(`unknown map ${raw}`); + args.maps.push(num()); + break; + case "--anomaly": { + const match = ANOMALIES.find((a) => a.id === raw); + if (!match) throw new Error(`unknown anomaly '${raw}'`); + args.anomalies.push(match.id); + break; + } + case "--time": + args.timeS = num(); + break; + case "--runs": + args.runs = Math.max(1, Math.floor(num())); + break; + case "--top": + args.top = Math.max(1, Math.floor(num())); + break; + case "--workers": + args.workers = Math.max(1, Math.floor(num())); + break; + case "--seed": + args.seed = Math.floor(num()); + break; + case "--json": + args.json = raw; + break; + case "--compare": + args.compare = raw; + break; + default: + throw new Error(`unrecognized argument: ${flag}`); + } + } + return args; +} + +function topLeft(plan: IslandPlan, index: number): { x: number; y: number } { + const island = plan.islands[index]; + let first = Infinity; + for (let t = 0; t < island.buildable.length; t++) + if (island.buildable[t]) + first = Math.min(first, island.originalTileIndices[t]); + return { + x: first % plan.originalWidth, + y: Math.floor(first / plan.originalWidth), + }; +} + +async function buildCases(args: Args): Promise { + const maps = args.maps.length + ? args.maps.map((n) => MAPS_BY_NUM.get(n)!) + : MAPS.filter((m) => m.num !== 0); + const anomalies = args.anomalies.length + ? args.anomalies + : // An older build of an anomaly (Tidal's pre-nerf build) is the same + // rule at a different number — surveying it by default would be a + // duplicate row reading as a second anomaly. + ANOMALIES.filter((a) => !a.variantOf).map((a) => a.id); + const unlocks = allUpgradesUnlocked(); + + const cases: Case[] = []; + for (const map of maps) { + const grid = clearObstacles((await decodeBlueprint(map.code)).grid); + for (const anomalyId of anomalies) { + const plan = planSolve( + grid, + [...BUILDINGS], + unlocks, + args.timeS, + undefined, + anomalyId, + ); + if (!plan) continue; + const rows = plan.islands.map((island, index) => ({ + map, + anomalyId, + index, + ...topLeft(plan, index), + tiles: island.tileCount, + budgetS: plan.budgetsS[index], + // One island at a time through the same function the plan's total + // comes from, so the rows sum to `plan.theoreticalMaxPower`. + bound: estimateTotalMaxPower( + [island], + plan.effectiveBuildings, + plan.anomaly, + ), + powers: [], + })); + cases.push({ map, anomalyId, plan, rows }); + } + } + return cases; +} + +async function solveAll(cases: Case[], args: Args): Promise { + // The pool posts `meta` through the worker and back, so it comes home a + // structured clone: an index into `rows`, never the row itself. + const rows = cases.flatMap((c) => c.rows); + const tasks = []; + for (const c of cases) + for (let run = 0; run < args.runs; run++) + for (const row of c.rows) + tasks.push({ + meta: rows.indexOf(row), + payload: { + kind: "island" as const, + island: c.plan.islands[row.index], + effectiveBuildings: c.plan.effectiveBuildings, + budgetS: row.budgetS, + seed: (args.seed + run * SEED_STRIDE + row.index) >>> 0, + anomaly: c.plan.anomaly, + }, + }); + + // Longest first, so the pool does not end on one big island and idle cores. + tasks.sort((a, b) => b.payload.budgetS - a.payload.budgetS); + + const totalS = tasks.reduce((s, t) => s + t.payload.budgetS, 0); + console.log( + `${tasks.length} island solves, ${totalS.toFixed(0)}s of search on ` + + `${args.workers} workers (~${Math.ceil(totalS / args.workers / 60)} min)\n`, + ); + + let done = 0; + const pool = new WorkerPool(WORKER_URL, args.workers); + try { + await pool.run(tasks, (i, result) => { + rows[i].powers.push((result as IslandLayout).powerOutput); + done++; + if (process.stdout.isTTY) + process.stdout.write(`\r ${done}/${tasks.length} solved`); + }); + } finally { + await pool.terminate(); + } + if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); +} + +const best = (row: IslandRow): number => Math.max(...row.powers); +const pct = (n: number): string => `${(n * 100).toFixed(1)}%`; +const share = (power: number, bound: number): number => + bound > 0 ? power / bound : 1; + +/** Tidal's two builds share a name, so a row names its multiplier too. */ +function anomalyName(id: AnomalyId): string { + const { name, variantLabel } = getAnomaly(id); + return variantLabel ? `${name} ${variantLabel}` : name; +} + +function pad(s: string, width: number, right = false): string { + if (s.length >= width) return s; + const fill = " ".repeat(width - s.length); + return right ? fill + s : s + fill; +} + +function printTable(header: string[], rows: string[][], rightFrom = 2): void { + const widths = header.map((h, c) => + Math.max(h.length, ...rows.map((r) => r[c].length)), + ); + const line = (cells: string[]) => + cells.map((cell, c) => pad(cell, widths[c], c >= rightFrom)).join(" "); + console.log(line(header)); + console.log(widths.map((w) => "-".repeat(w)).join(" ")); + for (const r of rows) console.log(line(r)); + console.log(); +} + +function report(cases: Case[], args: Args): void { + console.log( + "Per map and anomaly, obstacles cleared (sum of each island's best run)\n", + ); + printTable( + ["Map", "Anomaly", "Best", "Bound", "Eff", "Lost", "Islands at bound"], + cases.map((c) => { + const power = c.rows.reduce((s, r) => s + best(r), 0); + const bound = c.plan.theoreticalMaxPower; + const atBound = c.rows.filter( + (r) => share(best(r), r.bound) >= AT_BOUND, + ).length; + return [ + `${c.map.num} ${c.map.name}`, + anomalyName(c.anomalyId), + formatNumber(power), + formatNumber(bound), + pct(share(power, bound)), + formatNumber(Math.max(0, bound - power)), + `${atBound}/${c.rows.length}`, + ]; + }), + ); + + // Ranked by the share of the *map's* bound an island gives up: a 2-tile + // scrap at 50% matters less than a main landmass at 97%. + const ranked = cases + .flatMap((c) => + c.rows.map((row) => ({ + row, + lostOfMap: (row.bound - best(row)) / (c.plan.theoreticalMaxPower || 1), + })), + ) + .filter(({ row }) => share(best(row), row.bound) < AT_BOUND) + .sort((a, b) => b.lostOfMap - a.lostOfMap) + .slice(0, args.top); + + console.log( + `Largest gaps — the ${args.top} islands giving up the most of their map's bound\n`, + ); + if (!ranked.length) { + console.log("Every island reached its bound.\n"); + return; + } + printTable( + [ + "Map", + "Anomaly", + "Island", + "Tiles", + "Budget", + "Best", + "Bound", + "Eff", + "Of map", + "Run spread", + ], + ranked.map(({ row, lostOfMap }) => { + const lo = Math.min(...row.powers); + const hi = best(row); + return [ + `${row.map.num} ${row.map.name}`, + anomalyName(row.anomalyId), + `#${row.index} @${row.x},${row.y}`, + String(row.tiles), + `${row.budgetS.toFixed(1)}s`, + formatNumber(hi), + formatNumber(row.bound), + pct(share(hi, row.bound)), + `-${pct(lostOfMap)}`, + hi > 0 && lo < hi ? `${pct((hi - lo) / hi)}` : "none", + ]; + }), + 3, + ); + console.log( + "Run spread is how far the worst run fell below the best. A spread means\n" + + "more time or more restarts would help; none means every run found the\n" + + "same figure — a stuck search, or a bound that is not tight there.\n", + ); +} + +/** What `--json` writes and `--compare` reads. */ +interface SurveyFile { + timeS: number; + runs: number; + seed: number; + cases: { + map: number; + name: string; + anomaly: AnomalyId; + bound: number; + islands: { + index: number; + x: number; + y: number; + tiles: number; + budgetS: number; + bound: number; + powers: number[]; + }[]; + }[]; +} + +function writeJson(file: string, cases: Case[], args: Args): void { + const out: SurveyFile = { + timeS: args.timeS, + runs: args.runs, + seed: args.seed, + cases: cases.map((c) => ({ + map: c.map.num, + name: c.map.name, + anomaly: c.anomalyId, + bound: c.plan.theoreticalMaxPower, + islands: c.rows.map((r) => ({ + index: r.index, + x: r.x, + y: r.y, + tiles: r.tiles, + budgetS: r.budgetS, + bound: r.bound, + powers: r.powers, + })), + })), + }; + fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(out, null, 2) + "\n"); + console.log(`Wrote ${file}`); +} + +const mean = (xs: number[]): number => + xs.reduce((a, b) => a + b, 0) / (xs.length || 1); + +/** How far apart a set of runs landed, as a share of the best of them. */ +const spread = (xs: number[]): number => { + const hi = Math.max(...xs); + return hi > 0 ? (hi - Math.min(...xs)) / hi : 0; +}; + +/** + * Below this a change is noise however tight the runs were: two identical + * figures do not prove the next run would be identical too. + */ +const MIN_NOISE = 0.001; + +type Verdict = "better" | "worse" | "same"; + +/** + * Whether `after` moved past what run-to-run variation explains. + * + * Judged on the mean of the runs rather than the best: the best of three is + * the noisiest statistic there is, and a change that makes one lucky run + * likelier and the typical run worse should read as worse. The noise band is + * the wider of the two sides' spreads, so a search that got erratic cannot hide + * a drop behind its own new variance. + */ +function verdict( + before: number[], + after: number[], +): { + delta: number; + verdict: Verdict; +} { + const was = mean(before); + const delta = was > 0 ? (mean(after) - was) / was : 0; + const noise = Math.max(spread(before), spread(after), MIN_NOISE); + return { + delta, + verdict: delta < -noise ? "worse" : delta > noise ? "better" : "same", + }; +} + +const signed = (n: number): string => + `${n >= 0 ? "+" : ""}${(n * 100).toFixed(2)}%`; + +/** + * Sets this survey against a baseline `--json` wrote, and returns how many + * islands got worse. + * + * Cases pair by map and anomaly, islands by index and tile count. A bound that + * moved is called out rather than compared through: it means the roster or the + * rules changed, so a power change there is not the search's doing. + */ +function compare(cases: Case[], args: Args, base: SurveyFile): number { + const warnings: string[] = []; + if (base.timeS !== args.timeS) + warnings.push(`--time ${base.timeS} -> ${args.timeS}`); + if (base.runs !== args.runs) + warnings.push(`--runs ${base.runs} -> ${args.runs}`); + if (base.seed !== args.seed) + warnings.push(`--seed ${base.seed} -> ${args.seed}`); + if (warnings.length) + console.log( + `Baseline differs in ${warnings.join(", ")}: not like with like.\n`, + ); + + const rows: string[][] = []; + const worse: string[][] = []; + let worseCount = 0; + for (const c of cases) { + const label = [`${c.map.num} ${c.map.name}`, anomalyName(c.anomalyId)]; + const old = base.cases.find( + (b) => b.map === c.map.num && b.anomaly === c.anomalyId, + ); + if (!old) { + rows.push([...label, "-", "-", "-", "-", "not in baseline"]); + continue; + } + const sameSplit = + old.islands.length === c.rows.length && + old.islands.every((i, k) => i.tiles === c.rows[k].tiles); + if (!sameSplit) { + rows.push([...label, "-", "-", "-", "-", "islands split differently"]); + continue; + } + + // A map's runs are summed run by run, so the map-level spread is the + // spread of whole solves, as a player would see them. + const totals = (powers: number[][]): number[] => + powers[0].map((_, run) => powers.reduce((s, p) => s + (p[run] ?? 0), 0)); + const before = totals(old.islands.map((i) => i.powers)); + const after = totals(c.rows.map((r) => r.powers)); + const map = verdict(before, after); + + let notes = ""; + if (Math.abs(old.bound - c.plan.theoreticalMaxPower) > old.bound * 1e-9) + notes = "bound moved: rules or roster changed"; + for (let k = 0; k < c.rows.length; k++) { + const row = c.rows[k]; + const island = verdict(old.islands[k].powers, row.powers); + if (island.verdict !== "worse") continue; + worseCount++; + worse.push([ + ...label, + `#${row.index} @${row.x},${row.y}`, + String(row.tiles), + formatNumber(mean(old.islands[k].powers)), + formatNumber(mean(row.powers)), + signed(island.delta), + ]); + } + + rows.push([ + ...label, + formatNumber(mean(before)), + formatNumber(mean(after)), + signed(map.delta), + map.verdict, + notes, + ]); + } + + console.log("Against the baseline (mean of the runs)\n"); + printTable( + ["Map", "Anomaly", "Before", "After", "Change", "Verdict", "Notes"], + rows, + ); + if (worse.length) { + console.log("Islands that got worse than noise explains\n"); + printTable( + ["Map", "Anomaly", "Island", "Tiles", "Before", "After", "Change"], + worse, + 3, + ); + } else { + console.log("No island got worse than noise explains.\n"); + } + return worseCount; +} + +async function main(argv: string[]): Promise { + let args: Args | null; + try { + args = parseArgs(argv); + } catch (err) { + console.error(`survey: error: ${(err as Error).message}`); + return 2; + } + if (!args) return 0; + + // Read before solving, so a bad path fails now rather than after the run. + let base: SurveyFile | undefined; + if (args.compare) { + try { + base = JSON.parse(fs.readFileSync(args.compare, "utf8")) as SurveyFile; + } catch (err) { + console.error( + `survey: error: cannot read ${args.compare}: ${(err as Error).message}`, + ); + return 2; + } + } + + const cases = await buildCases(args); + await solveAll(cases, args); + report(cases, args); + if (args.json) writeJson(args.json, cases, args); + if (base && compare(cases, args, base) > 0) return 1; + return 0; +} + +main(process.argv.slice(2)).then( + (code) => process.exit(code), + (err) => { + console.error(err); + process.exit(1); + }, +); diff --git a/packages/solver/src/data/anomalies.ts b/packages/solver/src/data/anomalies.ts index ce40adf..1d16298 100644 --- a/packages/solver/src/data/anomalies.ts +++ b/packages/solver/src/data/anomalies.ts @@ -1,4 +1,8 @@ -import type { AnomalyDefinition, AnomalyId } from "../solver/types"; +import type { + AnomalyDefinition, + AnomalyId, + TerrainAffinityAnomaly, +} from "../solver/types"; /** * The anomalies a timeline can be run under. @@ -31,54 +35,109 @@ export const ANOMALIES: readonly AnomalyDefinition[] = [ "every building is worth exactly what its tier says, and heat and " + "cooling reach only the tiles beside them.", }, - { + cryoNexus(0.88), + // Nerfed from the legacy entry's figure; typed in ahead of the next + // extraction. + tidalAscendancy(1.35), + singularityIsolation(4, 0.8), + preNerf(tidalAscendancy(1.67), "tidal_ascendancy_legacy"), +]; + +/* + * One builder per rule, taking only the figures. Each figure is written once, + * at the call above, and every string that quotes it is filled from the same + * argument — the game's own `{0}` templates, resolved here rather than by hand, + * so a rebalance is a one-number edit that cannot leave the card text behind. + */ + +/** A figure as the game's cards print it: "×1.35", "×4". */ +function times(factor: number): string { + return `×${factor}`; +} + +function cryoNexus(coolerMultiplier: number): AnomalyDefinition { + return { id: "cryo_nexus", rule: "shared_cooling", name: "Cryo Nexus", benefit: "Cooling is shared per island", - drawback: "Heat Sink cooling ×0.88", + drawback: `Heat Sink cooling ${times(coolerMultiplier)}`, description: "All Heat Sinks on an island add their Cooling to one shared pool. It " + "can cool every Power Source on that island, no matter how far away it " + "is. If there is not enough Cooling, every Power Source receives the " + - "same percentage of what it needs. Each Heat Sink contributes ×0.88 of " + - "its normal Cooling. Cooling does not carry over to other islands.", - coolerMultiplier: 0.88, - }, - { + `same percentage of what it needs. Each Heat Sink contributes ` + + `${times(coolerMultiplier)} of its normal Cooling. Cooling does not ` + + "carry over to other islands.", + coolerMultiplier, + }; +} + +/** Typed by the shape it returns, so callers can spread it and re-id it. */ +function tidalAscendancy(multiplier: number) { + return { id: "tidal_ascendancy", rule: "terrain_affinity", name: "Tidal Ascendancy", - benefit: "Waterside buildings ×1.67", + benefit: `Waterside buildings ${times(multiplier)}`, drawback: "Inland buildings gain nothing", description: - "Production buildings next to water get a ×1.67 multiplier. Corners " + - "count too. The bonus affects Energy, Heat, Cooling, and overheat " + - "capacity. Buildings away from water work normally and get no bonus.", + `Production buildings next to water get a ${times(multiplier)} ` + + "multiplier. Corners count too. The bonus affects Energy, Heat, " + + "Cooling, and overheat capacity. Buildings away from water work " + + "normally and get no bonus.", // Water only. A pond looks wet and is not: the game files it with the rocks // and the trees as an obstacle, and it grants nothing. Between 36% and 44% // of the grass on every shipped map is water-adjacent, so this list decides // the rating of roughly half the board — it is the cheapest thing here to // get wrong. terrain: ["water"], - multiplier: 1.67, - }, - { + multiplier, + variantLabel: times(multiplier), + } satisfies TerrainAffinityAnomaly; +} + +/** + * An anomaly as it shipped before a rebalance. A save that jumped into it then + * keeps that figure until it jumps again, so it stays selectable — as a switch + * on the live card, not as a card of its own (see `variantOf`). Same name as + * the live one on purpose: in the game it *is* that anomaly. + */ +function preNerf( + entry: TerrainAffinityAnomaly, + id: AnomalyId, +): AnomalyDefinition { + return { + ...entry, + id, + description: + entry.description + + " This is the pre-nerf figure, which a timeline keeps until its next jump.", + variantOf: entry.id, + }; +} + +function singularityIsolation( + isolated: number, + crowded: number, +): AnomalyDefinition { + return { id: "singularity_isolation", rule: "role_isolation", name: "Singularity Isolation", - benefit: "Isolated generators ×4", - drawback: "Adjacent generators ×0.8", + benefit: `Isolated generators ${times(isolated)}`, + drawback: `Adjacent generators ${times(crowded)}`, description: - "A Generator with no other Generator next to it gets ×4 Energy " + - "output, Heat output, and overheat capacity. If another Generator " + - "touches it, including at a corner, those values drop to ×0.8. More " + - "neighbours do not make the penalty worse.", + "A Generator with no other Generator next to it gets " + + `${times(isolated)} Energy output, Heat output, and overheat ` + + "capacity. If another Generator touches it, including at a corner, " + + `those values drop to ${times(crowded)}. More neighbours do not make ` + + "the penalty worse.", role: "generator", - isolated: 4, - crowded: 0.8, - }, -]; + isolated, + crowded, + }; +} /** The anomaly a timeline runs under until the player picks otherwise. */ export const DEFAULT_ANOMALY_ID: AnomalyId = "none"; diff --git a/packages/solver/src/data/prestige.ts b/packages/solver/src/data/prestige.ts index 861d56e..51b1b87 100644 --- a/packages/solver/src/data/prestige.ts +++ b/packages/solver/src/data/prestige.ts @@ -42,7 +42,7 @@ export const PRESTIGE_UPGRADES: readonly PrestigeUpgrade[] = [ effect: "Cooling Output", description: "Boosts the Cooling output of all cooling systems.", roles: ["cooler"], - bonuses: [0.1, 0.25, 0.45, 0.7, 1.0], + bonuses: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], }, { id: "infinite_grid", @@ -62,7 +62,7 @@ export const PRESTIGE_UPGRADES: readonly PrestigeUpgrade[] = [ // makes, and a sustainable layout never fills it, so per // `docs/game-logic.md` it is never the binding constraint. roles: ["generator", "direct_producer"], - bonuses: [0.1, 0.25, 0.45, 0.7, 1.0], + bonuses: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], }, { id: "stellar_forge", @@ -87,7 +87,7 @@ export const PRESTIGE_UPGRADES: readonly PrestigeUpgrade[] = [ * as `["reactor"]` is that rule, not an approximation of it. */ roles: ["reactor"], - bonuses: [0.1, 0.2, 0.3, 0.4, 0.5], + bonuses: [0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5], }, ]; diff --git a/packages/solver/src/encoding/blueprint.ts b/packages/solver/src/encoding/blueprint.ts index 39d5bae..fece1c2 100755 --- a/packages/solver/src/encoding/blueprint.ts +++ b/packages/solver/src/encoding/blueprint.ts @@ -155,6 +155,10 @@ const ANOMALY_BYTE_MAP: Record = { cryo_nexus: 1, tidal_ascendancy: 2, singularity_isolation: 3, + // Byte 2 predates the nerf, so a code shared before it now reads at the live + // figure. There is no telling such a code from a new one, and the live figure + // is the rule a timeline started today runs under. + tidal_ascendancy_legacy: 4, }; const RESEARCH_BYTE_MAP: Record = { diff --git a/packages/solver/src/solver/context.ts b/packages/solver/src/solver/context.ts index a74a3a2..b2fc8d3 100755 --- a/packages/solver/src/solver/context.ts +++ b/packages/solver/src/solver/context.ts @@ -131,9 +131,10 @@ export interface IslandContext { * reads better — which is exactly why the flag has to promise `rate`'s whole * behaviour rather than only its per-tile half. It read `tileScale === null` * once, which was true under a shared cooling pool while every cooler was - * being scaled by 0.88: a stage taking the invitation above would have rated - * Cryo coolers at their unscaled figures and returned a layout over-cooled on - * paper that the game shuts down board-wide, with nothing failing anywhere. + * being scaled by its `coolerMultiplier`: a stage taking the invitation + * above would have rated Cryo coolers at their unscaled figures and returned + * a layout over-cooled on paper that the game shuts down board-wide, with + * nothing failing anywhere. * * So it is false under a terrain bonus (a per-tile scale) *and* under a role * scale, and true under the rules that leave the roster alone — including @@ -166,10 +167,10 @@ export interface IslandContext { * For the stages that count rather than place — hub fits, composition * targets, the polish candidates — which have no tile to ask `rate` about and * yet must reason in the units the board will hold. Under a shared cooling - * pool every cooler is worth x0.88 wherever it stands, and a count made from - * the plain figure under-provisions cooling by exactly that: every target the - * retarget proposed was short by 8-14%, and under a pool a short board is an - * offline board. + * pool every cooler is worth its `coolerMultiplier` wherever it stands, and a + * count made from the plain figure under-provisions cooling by exactly that: + * every target the retarget proposed was short by 8-14%, and under a pool a + * short board is an offline board. */ rateRole(building: EffectiveBuilding): EffectiveBuilding; /** @@ -191,7 +192,10 @@ export interface IslandContext { * are only two variants of each roster entry, so they are built once per * island and handed back for the rest of the solve. */ - rateIsolated(building: EffectiveBuilding, crowded: boolean): EffectiveBuilding; + rateIsolated( + building: EffectiveBuilding, + crowded: boolean, + ): EffectiveBuilding; /** * A whole-layout scratch buffer, reused by `simulateIsland` when a rule has * to resolve a layout before scoring it. @@ -364,17 +368,10 @@ export function buildIslandContext( neighbors[i] = Int32Array.from(found); } - const tileScale = terrainScales( - localGrid, - anomaly, - xs, - ys, - n, - waterAdjacent, - ); + const tileScale = terrainScales(localGrid, anomaly, xs, ys, n, waterAdjacent); // A uniform scale on one role, which is what Cryo Nexus does to coolers: the - // game applies its 0.88 to `CoolerBuilding.CoolingPerSec`, so it is what a - // cooler is worth rather than a charge levied at the pool. + // game applies its `coolerMultiplier` to `CoolerBuilding.CoolingPerSec`, so + // it is what a cooler is worth rather than a charge levied at the pool. // // Only ever one of these two is in force, because only one anomaly runs at a // time — so the product below is always a multiply by exactly 1.0 on one diff --git a/packages/solver/src/solver/island.ts b/packages/solver/src/solver/island.ts index ec61de9..ae4db12 100755 --- a/packages/solver/src/solver/island.ts +++ b/packages/solver/src/solver/island.ts @@ -529,12 +529,12 @@ function estimateIslandMaxPower( * rule that scales everything uniformly (a terrain bonus, a research) can never * make it bite: the cap rises with the generator it caps. A rule that scales * **one role** breaks that, and `role_isolation` is the one that does — - * Singularity Isolation rates a lone generator's intake x4 while leaving the - * reactors that fill it and the coolers that clear it alone, so the LP bought - * its extra heat by spending fewer tiles on generators than any arrangement of - * eight neighbours can serve. Without the cap the shipped maps' bound sits 6-9% - * above anything the board allows, and the efficiency figure reads 83-87% for - * layouts that are not 83-87% of anything. + * Singularity Isolation rates a lone generator's intake by its `isolated` bonus + * while leaving the reactors that fill it and the coolers that clear it alone, + * so the LP bought its extra heat by spending fewer tiles on generators than + * any arrangement of eight neighbours can serve. Without the cap the shipped + * maps' bound sits 6-9% above anything the board allows, and the efficiency + * figure reads 83-87% for layouts that are not 83-87% of anything. * * Under a shared cooling pool the cooler term is gone, because adjacency is: * the pool reaches the whole island, so every neighbour may be a reactor. @@ -890,13 +890,13 @@ function estimateMixedIslandMaxPower( * than scaled up afterwards. * * A role scale is uniform across the island — every cooler under a shared pool - * is x0.88 wherever it stands, and a search free to keep generators apart rates - * every one of them at the isolation bonus — so it is a property of the roster - * and belongs in the roster. Running the LP on that roster is a tighter bound + * is worth its `coolerMultiplier` wherever it stands, and a search free to + * keep generators apart rates every one of them at the isolation bonus — so it + * is a property of the roster and belongs in the roster. Running the LP on that roster is a tighter bound * than scaling its result by the largest factor: `islandMaxScale` had to scale * the *whole* estimate by the isolation bonus under Singularity, which rated - * reactors and coolers up as well — at x4 that is 3.8x the tight bound, so a - * near-optimal layout reads as 25% layout efficiency. Scaling only the generator entries is + * reactors and coolers up as well — at the shipped `isolated` that was 3.8x + * the tight bound, so a near-optimal layout reads as 25% layout efficiency. Scaling only the generator entries is * sound for the same reason as before — any layout under the rule is feasible * in this roster with an objective no larger — and it is the roster the bound * is entitled to assume. diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index f977ab8..f8df05d 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -266,10 +266,11 @@ function offlineProducers( * Against `ratedValue`, never `baseValue`: `heatProduced` is what the * distribution actually sent, capped by the tile's RATING, so measuring it * against the authored tier compares a scaled delivery with an unscaled ceiling - * and only calls a reactor under-fed below 1/k fill — 60% on a Tidal shore, - * 66.7% under a maxed Stellar Forge. The move then stops firing on much of what - * it exists for, with no number anywhere disagreeing. It is a named function so - * that the reading can be pinned by a test rather than only stated here. + * and only calls a reactor under-fed below 1/k fill for a tile rated k — on a + * Tidal shore k is its `multiplier`, under a maxed Stellar Forge the + * research's. The move then stops firing on much of what it exists for, with + * no number anywhere disagreeing. It is a named function so that the reading + * can be pinned by a test rather than only stated here. */ function isUnderFed(row: SimPlacedBuilding): boolean { return row.heatProduced > EPS && row.heatProduced < row.ratedValue - EPS; @@ -1185,10 +1186,12 @@ async function hillClimb( // the layout were being accepted, and the walk never climbed back. // // It is read off the PLAIN roster, so under a rule that rates a tile above it - // the walk runs colder than this calibration intends — a move on a x1.67 shore - // is worth x1.67 of one here. Scaling it by `islandRatingCeiling` was measured: - // Tidal on map 3 at 15s over three seeds went 176/178/179AC to 179/181/178AC, - // and Singularity (x4) at 30s over five seeds on map 3 142/140/142/142/140AC to + // the walk runs colder than this calibration intends — a move on a Tidal + // shore is worth its `multiplier` times one here. Scaling it by + // `islandRatingCeiling` was measured: Tidal (the pre-nerf + // `tidal_ascendancy_legacy`) on map 3 at 15s over three seeds went + // 176/178/179AC to 179/181/178AC, and Singularity at 30s over five seeds on + // map 3 142/140/142/142/140AC to // 140/139/140/142/142AC and on map 7 274/271/271/271/274AC to // 267/271/271/271/274AC — inside run-to-run noise, so the plain figure is kept. // Anyone re-measuring should use a longer budget and more seeds than that. @@ -2022,10 +2025,11 @@ function tileLoad(building: EffectiveBuilding, row: SimPlacedBuilding): number { * `ctx.rate` answers this for every rule but one: a role isolation multiplier is * a function of what a tile's NEIGHBOURS are, so `rate` is the identity under it * and `simulateIsland` resolves it per layout through `rateIsolated` instead. - * That leaves right-sizing measuring a x4 load against a x1 capacity: an - * isolated generator authored 320, rated 1280 and absorbing 300, was never - * offered the authored 120 tier that covers it at its own rating of 480, so the - * pass left 980 of intake nobody pays it to have — which is exactly the money it + * That leaves right-sizing measuring a load rated by `isolated` against an + * unrated capacity: an isolated generator authored 320, rated 1280 (at the + * `isolated` the rule shipped with) and absorbing 300, was never offered the + * authored 120 tier that covers it at its own rating of 480, so the pass left + * 980 of intake nobody pays it to have — which is exactly the money it * exists to hand back. In the other direction the plain figure is over-generous, * and there the re-simulation below catches it, so only the waste escaped. * @@ -2186,9 +2190,234 @@ export function downgradeOversized( */ const GATHER_BUDGET_MS = 150; +/** + * How long `evacuateMinorLandmasses` may spend, on top of `GATHER_BUDGET_MS` + * and taken first. Spent only on a board with producers on more than one + * landmass, and the pass stops as soon as a hub is placed — moving a + * 15-producer hub costs about 170ms on a desktop. The cap is sized for a phone, + * several times slower, where a tight one would make the pass silently do + * nothing; a second is small beside a 10-30s attempt. Out of time, the layout + * stays as it was. + */ +const EVACUATE_BUDGET_MS = 1000; + /** Chebyshev radius of the patch `gatherPooledCoolers` anchors on. */ const GATHER_ANCHOR_RADIUS = 2; +/** Each tile's landmass, numbered from 0, and how many there are. */ +function landmasses(ctx: IslandContext): { + component: Int32Array; + count: number; +} { + const component = new Int32Array(ctx.n).fill(-1); + let count = 0; + for (let start = 0; start < ctx.n; start++) { + if (component[start] >= 0) continue; + const id = count++; + const stack = [start]; + component[start] = id; + while (stack.length > 0) { + const nb = ctx.neighbors[stack.pop()!]; + for (let k = 0; k < nb.length; k++) { + if (component[nb[k]] < 0) { + component[nb[k]] = id; + stack.push(nb[k]); + } + } + } + } + return { component, count }; +} + +/** + * Under a shared cooling pool, moves the hubs off every landmass but the main + * one, so that landmass can be given to the coolers. + * + * `gatherPooledCoolers` moves one tile at a time, and that cannot move a hub: + * a generator taken from its reactors loses power at every step of the way, so + * the hub on a small second landmass stays there and its coolers stay with it. + * This moves a landmass's producers all at once instead. Each takes a tile on + * the main landmass, the one holding the most producers, and the cooler it + * displaces goes to one of the tiles the producers left — so the pool's total + * and the board's composition never change, and the landmass comes out + * producer-free for the sweep to fill. + * + * The producers are placed greedily, whichever of them is worth the most power + * on whichever free tile goes next, and then the main landmass is swapped + * into shape until power is back (`relocateHub`). A landmass is evacuated only if the result + * holds the power floor, keeps every producer online and passes the upgrade + * plan; otherwise the layout is left exactly as it was. Smallest first, since a + * hub of a few producers is the likeliest to fit. + */ +function evacuateMinorLandmasses( + current: Placement, + ctx: IslandContext, + component: Int32Array, + count: number, + floor: number, + deadlineMs: number, +): void { + const producersOn = new Array(count).fill(0); + for (let t = 0; t < ctx.n; t++) { + const b = current[t]; + if (b !== null && b.type !== "cooler") producersOn[component[t]]++; + } + let main = 0; + for (let c = 1; c < count; c++) + if (producersOn[c] > producersOn[main]) main = c; + + const minors: number[] = []; + for (let c = 0; c < count; c++) + if (c !== main && producersOn[c] > 0) minors.push(c); + minors.sort((a, b) => producersOn[a] - producersOn[b] || a - b); + + const trial = current.slice(); + for (const minor of minors) { + if (performance.now() >= deadlineMs) return; + copyInto(trial, current); + if (relocateHub(trial, ctx, component, minor, main, floor, deadlineMs)) + copyInto(current, trial); + } +} + +/** + * Moves every producer on `minor` onto `main`, in place. True when the result + * is a layout `evacuateMinorLandmasses` may keep; `trial` is junk otherwise. + */ +function relocateHub( + trial: Placement, + ctx: IslandContext, + component: Int32Array, + minor: number, + main: number, + floor: number, + deadlineMs: number, +): boolean { + const movers: EffectiveBuilding[] = []; + const vacated: number[] = []; + for (let t = 0; t < ctx.n; t++) { + const b = trial[t]; + if (component[t] !== minor || b === null || b.type === "cooler") continue; + movers.push(b); + vacated.push(t); + trial[t] = null; + } + + // A tile on the main landmass a producer may take: empty, or a cooler that + // can step across to the landmass being emptied. + const free = (t: number): boolean => + component[t] === main && (trial[t] === null || trial[t]!.type === "cooler"); + const freeAround = (t: number): number => { + let n = 0; + for (const u of ctx.neighbors[t]) if (free(u)) n++; + return n; + }; + // A reactor makes power only through a generator beside it, so a tile with + // none is not worth simulating. + const besideGenerator = (t: number): boolean => { + for (const u of ctx.neighbors[t]) + if (trial[u] !== null && trial[u]!.type === "generator") return true; + return false; + }; + + let nextVacated = 0; + while (movers.length > 0) { + if (performance.now() >= deadlineMs) return false; + + let bestPower = -Infinity; + let bestRoom = -1; + let bestTile = -1; + let bestMover = -1; + // A reactor beside no generator is still placed when nothing better is + // left — a generator that took the strongest tile may have taken the last + // free one beside it too — and the swap descent below moves it into place. + for (const anywhere of [false, true]) { + const tried = new Set(); + for (let m = 0; m < movers.length; m++) { + const b = movers[m]; + if (tried.has(b.id)) continue; + tried.add(b.id); + for (const t of ctx.tiles) { + if (!free(t)) continue; + if (!anywhere && isReactor(b) && !besideGenerator(t)) continue; + const held = trial[t]; + put(trial, ctx, t, b); + const power = simulateIsland(trial, ctx).totalPower; + trial[t] = held; + // On a tie, the tile with the most room left around it, so a + // generator lands where its reactors can follow. + const room = freeAround(t); + if (power > bestPower || (power === bestPower && room > bestRoom)) { + bestPower = power; + bestRoom = room; + bestTile = t; + bestMover = m; + } + } + } + if (bestTile >= 0) break; + } + if (bestTile < 0) return false; + + const displaced = trial[bestTile]; + put(trial, ctx, bestTile, movers[bestMover]); + // The displaced cooler crosses over at once, so the pool is whole while + // the rest of the hub is placed. + if (displaced !== null) put(trial, ctx, vacated[nextVacated++], displaced); + movers.splice(bestMover, 1); + } + + // The greedy order cannot see that a reactor placed early sits on the tile a + // later generator wanted, and the hubs already on the main landmass were + // built around a board that had no room for these. So: a swap descent over + // the whole landmass, nearby pairs only (as `arrangeComposition` does), first + // gain taken, until the floor is met. Swaps never change what is on the + // board, and the emptied landmass is not in the pairs, so nothing returns to + // it. On a cleared Entropy Isles under Cryo the greedy reaches 98.5% of the + // floor and one pass of this closes the rest. + const onMain: number[] = []; + for (const t of ctx.tiles) if (component[t] === main) onMain.push(t); + const pairs: [number, number][] = []; + for (let i = 0; i < onMain.length; i++) + for (let j = i + 1; j < onMain.length; j++) + if (chebyshev(ctx, onMain[i], onMain[j]) <= SWAP_RADIUS) + pairs.push([onMain[i], onMain[j]]); + + const producer = (b: EffectiveBuilding | null): boolean => + b !== null && b.type !== "cooler"; + let power = simulateIsland(trial, ctx).totalPower; + let improved = true; + while (improved && !covers(power, floor)) { + improved = false; + for (const [a, b] of pairs) { + if (performance.now() >= deadlineMs) return false; + const va = trial[a]; + const vb = trial[b]; + // Coolers and empty tiles are interchangeable under the pool. + if (!producer(va) && !producer(vb)) continue; + if (ratedFor(ctx, a, vb) === va) continue; + put(trial, ctx, a, vb); + put(trial, ctx, b, va); + const swapped = simulateIsland(trial, ctx).totalPower; + if (swapped > power) { + power = swapped; + improved = true; + if (covers(power, floor)) break; + continue; + } + put(trial, ctx, a, va); + put(trial, ctx, b, vb); + } + } + + const final = simulateIsland(trial, ctx); + return ( + covers(final.totalPower, floor) && + !anyOfflineProducer(trial, final.placements) && + planHolds(trial, ctx) + ); +} + /** * Under a shared cooling pool, pulls the coolers into one area of the board * and onto the small islands no producer is using. @@ -2221,6 +2450,10 @@ function gatherPooledCoolers( effectiveBuildings: EffectiveBuilding[], ctx: IslandContext, deadlineMs: number, + evacuateDeadlineMs = Math.min( + deadlineMs, + performance.now() + EVACUATE_BUDGET_MS, + ), ): { rows: SimPlacedBuilding[]; power: number } { if (ctx.anomaly.rule !== "shared_cooling" || rows.length === 0) return { rows, power }; @@ -2233,29 +2466,24 @@ function gatherPooledCoolers( put(current, ctx, row.idx, building); } - // Landmasses, and which of them hold a producer, read once from the layout - // as handed over. - const component = new Int32Array(ctx.n).fill(-1); - const working: boolean[] = []; - for (let start = 0; start < ctx.n; start++) { - if (component[start] >= 0) continue; - const id = working.length; - let hasProducer = false; - const stack = [start]; - component[start] = id; - while (stack.length > 0) { - const t = stack.pop()!; - const b = current[t]; - if (b !== null && b.type !== "cooler") hasProducer = true; - const nb = ctx.neighbors[t]; - for (let k = 0; k < nb.length; k++) { - if (component[nb[k]] < 0) { - component[nb[k]] = id; - stack.push(nb[k]); - } - } - } - working.push(hasProducer); + const { component, count } = landmasses(ctx); + // The callers' deadline covers both budgets; capping the evacuation at its + // own (the default) leaves the sweep at least `GATHER_BUDGET_MS`. + evacuateMinorLandmasses( + current, + ctx, + component, + count, + power, + evacuateDeadlineMs, + ); + + // Which landmasses hold a producer, read after the evacuation so a landmass + // it emptied fills with coolers first, like any other scrap. + const working = new Array(count).fill(false); + for (let t = 0; t < ctx.n; t++) { + const b = current[t]; + if (b !== null && b.type !== "cooler") working[component[t]] = true; } const movable = (t: number): boolean => { @@ -2373,7 +2601,8 @@ function finalizeAlternates( // One gathering budget for the whole shortlist rather than one each, so a // long shortlist cannot add a second to the run. An alternate the deadline // cuts short is still valid, only less gathered. - const gatherDeadlineMs = performance.now() + GATHER_BUDGET_MS; + const gatherDeadlineMs = + performance.now() + EVACUATE_BUDGET_MS + GATHER_BUDGET_MS; for (const layout of collector.layouts()) { const pruned = pruneDeadWeight(layout, ctx); @@ -2538,7 +2767,7 @@ export async function solveIsland( } // The pools every stage draws on, in the units the board will hold: a rule - // that scales a whole role (a cooling pool's x0.88) is folded in here, so the + // that scales a whole role (a cooling pool's `coolerMultiplier`) is folded in here, so the // stages that count from the roster rather than place on a tile count the // right figure. `rate` is idempotent, so a pool entry that already carries // its role's rating is handed straight back at the write. @@ -2787,7 +3016,7 @@ export async function solveIsland( downsized.power, effectiveBuildings, ctx, - performance.now() + GATHER_BUDGET_MS, + performance.now() + EVACUATE_BUDGET_MS + GATHER_BUDGET_MS, ); const placements = toPlacedBuildings(sized.rows); return { diff --git a/packages/solver/src/solver/simulate.ts b/packages/solver/src/solver/simulate.ts index 3c4b362..06d6c88 100755 --- a/packages/solver/src/solver/simulate.ts +++ b/packages/solver/src/solver/simulate.ts @@ -23,8 +23,9 @@ export interface SimPlacedBuilding extends PlacedBuilding { * two differ by the multiplier, and a consumer comparing a delivery against * the wrong one silently reads a full tile as a starved one (or the reverse): * the walk's "under-fed reactor" move measured `heatProduced` against - * `baseValue` for a while and stopped firing below x1/k fill — 66.7% under a - * maxed Stellar Forge, 60% on a Tidal shore — with nothing failing anywhere. + * `baseValue` for a while and stopped firing below 1/k fill for a tile + * rated k — under a maxed Stellar Forge or on a Tidal shore — with nothing + * failing anywhere. * * It stays on `SimPlacedBuilding` rather than on `PlacedBuilding`, so it does * not cross the worker boundary: it is the search's own units, meaningless to @@ -243,9 +244,10 @@ export function simulateIsland( * board-wide all-or-nothing the rule describes, with no second code * path and no board-level flag. * - * The 0.88 is already in each cooler's `effectiveValue` — the game rates - * the cooler down rather than charging it at the pool — so this sums - * what the buildings are worth, exactly as the local path does. + * The pool's `coolerMultiplier` is already in each cooler's + * `effectiveValue` — the game rates the cooler down rather than charging + * it at the pool — so this sums what the buildings are worth, exactly as + * the local path does. */ let totalCooling = 0.0; for (let i = 0; i < numCoolers; i++) diff --git a/packages/solver/src/solver/solver.ts b/packages/solver/src/solver/solver.ts index 8eed172..6921dea 100755 --- a/packages/solver/src/solver/solver.ts +++ b/packages/solver/src/solver/solver.ts @@ -31,6 +31,84 @@ import type { export const DEFAULT_TIME_BUDGET_S = 10.0; +/** + * How steeply an island's share of the budget grows with its size: a share is + * weighted by tile count to this power, not by tile count. + * + * Measured, not reasoned (`scripts/gapSurvey.ts` is the instrument). Solving + * every island of the eight cleared maps at a quarter, half, one and two times + * its old proportional share: an island of 20 tiles or fewer reached the same + * figure at a quarter as at double — most hit their bound or the same loose + * figure in a few hundredths of a second — while every landmass of 67 tiles + * and up was still climbing at double. Under a linear split those scraps held + * up to 19% of a run (Entropy Isles); squared, they hold under 2% and the main + * landmass gets the rest. Shadowspire, the one map with two large landmasses, + * also gained more per second on its 165-tile one than on its 79-tile one, which + * is the same direction. + */ +const BUDGET_EXPONENT = 2; + +/** + * The least an island is cut down to, in seconds — or its old proportional + * share, if that was less. Enough for a scrap's seed and repair to finish on a + * phone several times slower than the desktop the measurements above ran on; + * on that desktop they need about a quarter of this. + */ +const MIN_ISLAND_BUDGET_S = 0.25; + +/** + * Each island's slice of `totalS`, by buildable-tile count. The slices sum to + * `totalS`. + * + * Shares grow with tile count to `BUDGET_EXPONENT`, so time goes where it still + * buys power, but no island drops below the smaller of `MIN_ISLAND_BUDGET_S` + * and its proportional share. Those floors are filled first and the rest is + * split by weight, re-pinning any island the split would put under its floor + * until none would. + * + * Exported because the app's run estimate (`taskDurationsMs`) has to model + * exactly the budgets the coordinator will send; a second copy of this rule is + * an estimate that quietly describes a different run. + */ +export function islandBudgetsS( + tileCounts: readonly number[], + totalS: number, +): number[] { + const n = tileCounts.length; + const tiles = tileCounts.reduce((a, b) => a + b, 0); + if (n === 0) return []; + // Nothing to search anywhere, so nothing to spend. + if (tiles === 0) return tileCounts.map(() => 0); + + const floors = tileCounts.map((c) => + Math.min(MIN_ISLAND_BUDGET_S, (totalS * c) / tiles), + ); + const weights = tileCounts.map((c) => c ** BUDGET_EXPONENT); + const pinned = new Array(n).fill(false); + const budgets = new Array(n).fill(0); + + for (;;) { + let free = totalS; + let weight = 0; + for (let i = 0; i < n; i++) { + if (pinned[i]) free -= floors[i]; + else weight += weights[i]; + } + let repinned = false; + for (let i = 0; i < n; i++) { + if (pinned[i]) continue; + budgets[i] = weight > 0 ? (free * weights[i]) / weight : 0; + if (budgets[i] < floors[i]) { + pinned[i] = true; + repinned = true; + } + } + if (!repinned) break; + } + for (let i = 0; i < n; i++) if (pinned[i]) budgets[i] = floors[i]; + return budgets; +} + export interface IslandPlan { islands: IslandSubGrid[]; effectiveBuildings: EffectiveBuilding[]; @@ -40,7 +118,7 @@ export interface IslandPlan { * "no anomaly" is one of them. */ anomaly: AnomalyDefinition; - /** Per-island time budget, proportional to buildable-tile count. */ + /** Per-island time budget — see `islandBudgetsS`. */ budgetsS: number[]; /** Per-island RNG seed, or undefined for a fresh stochastic stream. */ seeds: (number | undefined)[]; @@ -60,8 +138,8 @@ export interface IslandPlan { * decomposition, the roster, and how the time budget is divided. * * Islands never interact — nothing that happens on one can affect another — so - * each gets its full proportional share of the budget and they may run in any - * order, or concurrently. + * each gets its full share of the budget (`islandBudgetsS`) and they may run + * in any order, or concurrently. */ export function planSolve( grid: Tile[][], @@ -104,15 +182,13 @@ export function planSolve( ); if (islands.length === 0) return null; - const grassCounts = islands.map((island) => island.tileCount); - const totalIslandGrass = grassCounts.reduce((a, b) => a + b, 0) || 1; - return { islands, effectiveBuildings, anomaly, - budgetsS: grassCounts.map( - (count) => timeBudgetS * (count / totalIslandGrass), + budgetsS: islandBudgetsS( + islands.map((island) => island.tileCount), + timeBudgetS, ), // Each island gets its own random stream, offset from the base seed so a // seeded solve is reproducible per island rather than coupled across them. diff --git a/packages/solver/src/solver/types.ts b/packages/solver/src/solver/types.ts index bcd5416..31e7419 100755 --- a/packages/solver/src/solver/types.ts +++ b/packages/solver/src/solver/types.ts @@ -178,9 +178,7 @@ export interface EffectiveBuilding { * here, and naming it here means giving it a byte. */ export type PrestigeUpgradeId = - | "absolute_zero" - | "infinite_grid" - | "stellar_forge"; + "absolute_zero" | "infinite_grid" | "stellar_forge"; export interface PrestigeUpgrade { id: PrestigeUpgradeId; @@ -216,7 +214,8 @@ export type AnomalyId = | "none" | "cryo_nexus" | "tidal_ascendancy" - | "singularity_isolation"; + | "singularity_isolation" + | "tidal_ascendancy_legacy"; /** * Identity and the game's own wording, shared by every anomaly. @@ -235,6 +234,15 @@ export interface AnomalyBase { drawback: string; /** The full rule, near enough verbatim. */ description: string; + /** + * Set on an older build of another anomaly that a save can still be running + * under — the game re-rates a timeline only when it jumps again, so a nerf + * leaves the old figure live until then. A variant is not a card of its own: + * the selector folds it under the anomaly it names, as a switch on that card. + */ + variantOf?: AnomalyId; + /** What that switch calls this build — its figure. Set on both sides of it. */ + variantLabel?: string; } /** The base rules, unmodified. */ diff --git a/packages/solver/src/utils/formatters.ts b/packages/solver/src/utils/formatters.ts index 0de6179..afe70da 100644 --- a/packages/solver/src/utils/formatters.ts +++ b/packages/solver/src/utils/formatters.ts @@ -77,6 +77,71 @@ export function formatNumber(n: number): string { return (neg ? "-" : "") + formatted + SUFFIXES[clamped]; } +/** + * The whole figure, for a reader who wants every digit `formatNumber` rounded + * away: digits in comma-grouped threes, with any trailing run of `000` groups + * folded into the suffix that names it. + * + * 6.11210546e23 → "611,210,546AA" + * 1234567 → "1,234,567" + * 2e6 → "2M" + * 1234.5 → "1,234.5" + * + * The digits are the double's **shortest round-trip** spelling (`toString`), + * not its exact binary value: past 2^53 a double carries no more than ~17 + * significant digits, and printing what `BigInt` would make of the rest is + * printing noise as if it were measured. So a large figure always ends in + * zeros, and those are exactly what the suffix folds. A fraction is kept to + * the two places `formatNumber` shows below 1000, and a figure with one has no + * trailing zero groups to fold. + */ +export function formatNumberFull(n: number): string { + if (Number.isNaN(n)) return "NaN"; + if (!isFinite(n)) return n > 0 ? "∞" : "-∞"; + + const abs = Math.round(Math.abs(n) * 100) / 100; + if (abs === 0) return "0"; + const sign = n < 0 ? "-" : ""; + + const [intDigits, fracDigits] = plainDigits(abs); + const groups: string[] = []; + for (let end = intDigits.length; end > 0; end -= 3) { + groups.unshift(intDigits.slice(Math.max(0, end - 3), end)); + } + + let tier = 0; + if (!fracDigits) { + while ( + groups.length > 1 && + groups[groups.length - 1] === "000" && + tier < SUFFIXES.length - 1 + ) { + groups.pop(); + tier++; + } + } + + const frac = fracDigits ? `.${fracDigits}` : ""; + return sign + groups.join(",") + frac + SUFFIXES[tier]; +} + +/** + * A positive double's shortest spelling as integer and fraction digits, with + * e-notation expanded — `toString` switches to it at 1e21, which is where the + * figures this exists for live. + */ +function plainDigits(x: number): [string, string] { + const [mantissa, exp] = x.toString().split("e"); + const [whole, frac = ""] = mantissa.split("."); + if (exp === undefined) return [whole, frac]; + + // Callers have rounded to hundredths, so only a positive exponent reaches + // here; the digits are then all integer, padded out with zeros. + const digits = whole + frac; + const intLength = whole.length + Number(exp); + return [digits.padEnd(intLength, "0"), ""]; +} + /** * The same figure, spelled for a filename. Both writers of one use it — the * app naming a saved picture, and the CLI naming the blueprint it writes into diff --git a/packages/solver/tests/anomalies.test.ts b/packages/solver/tests/anomalies.test.ts index 922ee35..51fdc91 100644 --- a/packages/solver/tests/anomalies.test.ts +++ b/packages/solver/tests/anomalies.test.ts @@ -17,6 +17,7 @@ import { getAnomaly, } from "../src/data/anomalies"; import { BUILDINGS } from "../src/data/buildings"; +import { decodeBlueprint } from "../src/encoding/blueprint"; import { getEffectiveBuildings, scaleEffectiveBuilding, @@ -33,7 +34,14 @@ import { splitGridIntoIslands, } from "../src/solver/island"; import { internals, solveIsland } from "../src/solver/placementSearch"; -import { basicCatalogue, basicRoster, cooler, reactor } from "./helpers"; +import { planSolve } from "../src/solver/solver"; +import { + anomalyOfRule, + basicCatalogue, + basicRoster, + cooler, + reactor, +} from "./helpers"; import type { AnomalyDefinition, EffectiveBuilding, @@ -43,9 +51,16 @@ import type { } from "../src/solver/types"; /* - * A generator whose figures part company under the shipped x1.67: the pair - * scales to 167 and 100 x 1.67 is 167.00000000000003, so a test on it can tell - * a derived waste from a scaled one. `scaleEffectiveBuilding`'s own file pins + * The factor the scaling cases below use: pre-nerf Tidal's shore bonus. A + * literal on purpose, not read off `data/anomalies.ts` — the exact doubles + * those cases pin belong to this number alone, so a rebalance must not move it. + */ +const SHORE = 1.67; + +/* + * A generator whose figures part company under `SHORE`: the pair scales to + * 167, and 100 x `SHORE` is 167.00000000000003, so a test on it can tell a + * derived waste from a scaled one. `scaleEffectiveBuilding`'s own file pins * that against the real catalogue; this fixture is here so the cases below, * which are about the other three figures, cannot pass under the rule this one * replaced. @@ -105,13 +120,13 @@ describe("scaling a resolved building", () => { * satisfy `waste === snap(heat - energy)` at its chosen factor, so it read * as a statement about scaling and could not fail if scaling came back. */ - const scaled = scaleEffectiveBuilding(generator, 1.67); + const scaled = scaleEffectiveBuilding(generator, SHORE); expect(scaled.effectiveValue).toBe(167); expect(scaled.energy).toBeCloseTo(116.9, 9); expect(scaled.waste).toBe(50.1); // Scaling the authored waste instead gives 50.099999999999994. - expect(scaled.waste).not.toBe(generator.waste * 1.67); + expect(scaled.waste).not.toBe(generator.waste * SHORE); // And the authored tier value never moves, whatever the factor. expect(scaled.baseValue).toBe(100); }); @@ -120,7 +135,7 @@ describe("scaling a resolved building", () => { // The point of scaling uniformly. Cooling that exactly covered the base // building must not still cover the bonused one, or a multiplier would be // free power rather than a bigger bet. - const scaled = scaleEffectiveBuilding(generator, 1.67); + const scaled = scaleEffectiveBuilding(generator, SHORE); expect(wasteIsCovered(generator.waste, generator.waste)).toBe(true); expect(wasteIsCovered(scaled.waste, generator.waste)).toBe(false); expect(wasteIsCovered(scaled.waste, scaled.waste)).toBe(true); @@ -138,19 +153,19 @@ describe("scaling a resolved building", () => { describe("a shared cooling pool reaching the board", () => { /* * The one rule that reaches across the whole board rather than a tile or a - * neighbourhood. Two halves: every cooler is re-rated x0.88 — the game - * applies that to `CoolingPerSec`, so it is what a cooler is worth — and the - * cooling half of the distribution is replaced by one pool that ignores - * adjacency entirely. + * neighbourhood. Two halves: every cooler is re-rated by `coolerMultiplier` — + * the game applies that to `CoolingPerSec`, so it is what a cooler is worth — + * and the cooling half of the distribution is replaced by one pool that + * ignores adjacency entirely. */ - const cryo = getAnomaly("cryo_nexus"); + const cryo = anomalyOfRule("cryo_nexus", "shared_cooling"); // A cooler deliberately smaller than two generators' waste, so "the pool - // falls short" is a case these boards can actually reach: 30 x 0.88 is 26.4 - // against 25 of waste per generator. + // falls short" is a case these boards can actually reach: 30 rated down by + // the pool covers one generator's 25 of waste and falls well short of two. const roster = basicRoster({ reactorValue: 500, coolerValue: 30 }); const [REACTOR, GENERATOR, COOLER] = roster; - const ctxFor = (rows: string[], anomaly = cryo) => + const ctxFor = (rows: string[], anomaly: AnomalyDefinition = cryo) => buildIslandContext(makeGrid(rows), undefined, anomaly); /** Place `spec` — `[x, y, building]` — rating each for its tile. */ @@ -172,7 +187,10 @@ describe("a shared cooling pool reaching the board", () => { const ctx = ctxFor(["RRRR", "RGGR", "RRRR"]); const tile = ctx.tiles[0]; - expect(ctx.rate(tile, COOLER).effectiveValue).toBeCloseTo(30 * 0.88, 9); + expect(ctx.rate(tile, COOLER).effectiveValue).toBeCloseTo( + 30 * cryo.coolerMultiplier, + 9, + ); // The authored tier is what identifies it and never moves. expect(ctx.rate(tile, COOLER).baseValue).toBe(30); expect(ctx.rate(tile, REACTOR)).toBe(REACTOR); @@ -273,7 +291,7 @@ describe("the seed under a shared cooling pool", () => { * * A 4x4 block beside three scraps: one tile on its own and a pair. Two-tile * hubs (one reactor fills one generator) fit the block exactly eight times, - * and the waste those make needs three coolers at x0.88 — the three scraps. + * and the waste those make needs three pool-rated coolers — the three scraps. */ const BOARD = ["GGGGRG", "GGGGRR", "GGGGRG", "GGGGRG"]; const roster = basicRoster(); @@ -415,6 +433,79 @@ describe("gathering the coolers under a shared cooling pool", () => { expect(out.rows).toBe(before.placements); expect(out.power).toBe(before.totalPower); }); + + it("moves a hub off a second landmass so the coolers can have it", async () => { + /* + * A solve of Entropy Isles with its obstacles cleared, reported by a + * player: a hub of 3 generators and 12 reactors on the 20-tile landmass + * west of the main one, with 34 coolers left on the main landmass. One + * tile at a time cannot move the hub — a generator taken from its reactors + * loses power at every step — and the player's own rearrangement put the + * hub on the main landmass and the coolers on the west one at the same + * 611AC. + */ + const CODE = + "eJx1kFkOwzAIRL20-Z6_SvQcc_-rVYCNHZOiSEl4LMPU76fMoMdVUpCij4C3NBSJdwlTEyxNEgmJEwHgY9ZA3QZtNbbqGR8QBsK22qGPBPS3TFHjAOsyzlOLTcTAN0MO9Yjb0sUAdZw8mFEwfAo29iB8l1i1XY7p1GC7KarNuuZhS6H7bjW7v7Z0KtjK4511pzv-EzyxI9dxyYvv2mtrrfcfKAoi8Q"; + const board = await decodeBlueprint(CODE); + const cryo = getAnomaly("cryo_nexus"); + const plan = planSolve( + board.grid, + [...BUILDINGS], + board.tiers, + 1, + 0, + cryo.id, + prestigeScales(board.rules!.research), + )!; + const island = plan.islands[0]; + const ctx = buildIslandContext(island.grid, island.buildable, cryo); + const original = (t: number) => + island.originalTileIndices[ctx.ys[t] * island.width + ctx.xs[t]]; + const tileAt = new Map(Array.from(ctx.tiles, (t) => [original(t), t])); + const byId = new Map(plan.effectiveBuildings.map((b) => [b.id, b])); + + const placement: Placement = new Array(ctx.n).fill(null); + for (const p of board.placements) { + const t = tileAt.get(p.y * board.width + p.x)!; + placement[t] = ctx.rate(t, byId.get(p.buildingId)!); + } + const before = simulateIsland(placement, ctx); + const out = internals.gatherPooledCoolers( + before.placements, + before.totalPower, + plan.effectiveBuildings, + ctx, + // Generous, evacuation included, so a loaded machine tests the pass + // rather than its deadlines. + performance.now() + 10_000, + performance.now() + 10_000, + ); + + expect(wasteIsCovered(before.totalPower, out.power)).toBe(true); + const counts = (rows: { buildingId: string }[]) => { + const n = new Map(); + for (const r of rows) n.set(r.buildingId, (n.get(r.buildingId) ?? 0) + 1); + return [...n].sort(); + }; + expect(counts(out.rows)).toEqual(counts(before.placements)); + for (const row of out.rows) + if (row.buildingId.startsWith("generator")) + expect(row.powerGenerated).toBeGreaterThan(EPS); + + // The west landmass: every tile 4-connected to (0, 7) on land. + const west = new Set(); + const stack = [tileAt.get(7 * board.width)!]; + while (stack.length > 0) { + const t = stack.pop()!; + if (west.has(t)) continue; + west.add(t); + for (const u of ctx.neighbors[t]) stack.push(u); + } + expect(west.size).toBe(20); + const onWest = out.rows.filter((r) => west.has(r.idx)); + expect(onWest.length).toBe(20); + expect(onWest.every((r) => r.buildingId.startsWith("cooler"))).toBe(true); + }); }); describe("role isolation reaching the board", () => { @@ -426,10 +517,9 @@ describe("role isolation reaching the board", () => { * * The roster gives the reactor far more heat than the generator can take, so * the generator is never starved: with a reactor only as large as the - * generator's authored intake, a x4 intake bonus buys nothing at all and - * every case below would read as 1.0. - */ - const singularity = getAnomaly("singularity_isolation"); + * generator's authored intake, an `isolated` intake bonus buys nothing at all + * and every case below would read as 1.0. */ + const singularity = anomalyOfRule("singularity_isolation", "role_isolation"); const roster = basicRoster({ reactorValue: 500, coolerValue: 200, @@ -444,26 +534,24 @@ describe("role isolation reaching the board", () => { }; /** A 7x5 board of grass walled in by rock, so no tile of it is on an edge. */ - const BOARD = [ - "RRRRRRR", - "RGGGGGR", - "RGGGGGR", - "RGGGGGR", - "RRRRRRR", - ]; + const BOARD = ["RRRRRRR", "RGGGGGR", "RGGGGGR", "RGGGGGR", "RRRRRRR"]; /** * Scores `spec` — `[x, y, code]` in board coordinates — and returns each * building's own power, keyed "x,y". Run twice per case, under the anomaly * and under none, so what is compared is one layout against itself. */ - const powers = (spec: [number, number, string][], anomaly = singularity) => { + const powers = ( + spec: [number, number, string][], + anomaly: AnomalyDefinition = singularity, + ) => { const ctx = buildIslandContext(makeGrid(BOARD), undefined, anomaly); const at = new Map(); for (let t = 0; t < ctx.n; t++) at.set(`${ctx.xs[t]},${ctx.ys[t]}`, t); const placement: Placement = new Array(ctx.n).fill(null); - for (const [x, y, code] of spec) placement[at.get(`${x},${y}`)!] = CODES[code]; + for (const [x, y, code] of spec) + placement[at.get(`${x},${y}`)!] = CODES[code]; const out = new Map(); for (const row of simulateIsland(placement, ctx, true).placements) @@ -480,7 +568,7 @@ describe("role isolation reaching the board", () => { const base = powers(spec, getAnomaly("none")).get("2,1")!; expect(base).toBeGreaterThan(0); - expect(powers(spec).get("2,1")).toBeCloseTo(base * 4, 6); + expect(powers(spec).get("2,1")).toBeCloseTo(base * singularity.isolated, 6); }); it("penalises both generators the moment they touch", () => { @@ -499,14 +587,20 @@ describe("role isolation reaching the board", () => { const crowded = powers(spec); expect(base.get("2,1")).toBeGreaterThan(0); - expect(crowded.get("2,1")).toBeCloseTo(base.get("2,1")! * 0.8, 6); - expect(crowded.get("3,1")).toBeCloseTo(base.get("3,1")! * 0.8, 6); + expect(crowded.get("2,1")).toBeCloseTo( + base.get("2,1")! * singularity.crowded, + 6, + ); + expect(crowded.get("3,1")).toBeCloseTo( + base.get("3,1")! * singularity.crowded, + 6, + ); }); it("costs no more for a second neighbour than for the first", () => { // Three generators in a row, each with a reactor above it and a cooler // below. The middle one touches two generators and the outer ones touch - // one; all three take exactly x0.8 — the rule is a test, not a count. + // one; all three take exactly `crowded` — the rule is a test, not a count. const spec: [number, number, string][] = [ [2, 1, "r"], [3, 1, "r"], @@ -524,7 +618,10 @@ describe("role isolation reaching the board", () => { for (const key of ["2,2", "3,2", "4,2"]) { expect(base.get(key)).toBeGreaterThan(0); - expect(crowded.get(key), key).toBeCloseTo(base.get(key)! * 0.8, 6); + expect(crowded.get(key), key).toBeCloseTo( + base.get(key)! * singularity.crowded, + 6, + ); } }); @@ -546,7 +643,10 @@ describe("role isolation reaching the board", () => { const base = powers(spec, getAnomaly("none")); const under = powers(spec); - expect(under.get("2,1")).toBeCloseTo(base.get("2,1")! * 4, 6); + expect(under.get("2,1")).toBeCloseTo( + base.get("2,1")! * singularity.isolated, + 6, + ); // And the turbine beside it is rated exactly as authored. expect(under.get("3,1")).toBeCloseTo(base.get("3,1")!, 6); }); @@ -577,10 +677,12 @@ describe("a terrain bonus reaching the board", () => { * settles it once and `rate` is a lookup — the reason a shore bonus costs the * search nothing. */ - const tidal = getAnomaly("tidal_ascendancy"); + // The pre-nerf build, whose figure is frozen: the exact 167s below are 100 + // at its multiplier, snapped to the authored precision. + const tidal = anomalyOfRule("tidal_ascendancy_legacy", "terrain_affinity"); /** The context for a board, with off-board treated as water throughout. */ - const contextFor = (rows: string[], anomaly = tidal) => + const contextFor = (rows: string[], anomaly: AnomalyDefinition = tidal) => buildIslandContext(makeGrid(rows), undefined, anomaly); it("rates a shore tile up and an inland tile as authored", () => { @@ -629,7 +731,7 @@ describe("a terrain bonus reaching the board", () => { expect(onShore.effectiveValue).toBe(167); // Shore -> shore, twice over. expect(ctx.rate(shore, onShore)).toBe(onShore); - // Shore -> inland gives the authored building back, not 167 x 1.67. + // Shore -> inland gives the authored building back, not 167 rated again. expect(ctx.rate(inland, onShore)).toBe(base); // And back again. expect(ctx.rate(shore, ctx.rate(inland, onShore))).toBe(onShore); @@ -652,15 +754,20 @@ describe("a terrain bonus reaching the board", () => { * `uniformRating` is the promise that `rate` is the identity, and it invites * a stage to skip the call — so it has to cover the role scale as well as * the per-tile one. It read `tileScale === null` once, which said "uniform" - * while every cooler on the board was being rated x0.88: a stage taking the - * invitation would have returned a layout 13.6% over-cooled on paper that - * the game shuts down board-wide, with nothing failing. + * while every cooler on the board was being rated by the pool: a stage + * taking the invitation would have returned a layout over-cooled on paper by + * 1/`coolerMultiplier` that the game shuts down board-wide, with nothing + * failing. */ - const ctx = contextFor(["GGGGG", "GGGGG"], getAnomaly("cryo_nexus")); + const cryo = anomalyOfRule("cryo_nexus", "shared_cooling"); + const ctx = contextFor(["GGGGG", "GGGGG"], cryo); const base = cooler(100); expect(ctx.uniformRating).toBe(false); - expect(ctx.rate(ctx.tiles[0], base).effectiveValue).toBeCloseTo(88, 9); + expect(ctx.rate(ctx.tiles[0], base).effectiveValue).toBeCloseTo( + 100 * cryo.coolerMultiplier, + 9, + ); // Only the cooler role, and the same answer on every tile. expect(ctx.rate(ctx.tiles[1], base)).toBe(ctx.rate(ctx.tiles[0], base)); }); @@ -719,7 +826,14 @@ describe("a terrain bonus reaching the board", () => { ); const byId = new Map(roster.map((b) => [b.id, b])); - const solution = await solveIsland(island, roster, 0.5, undefined, 11, tidal); + const solution = await solveIsland( + island, + roster, + 0.5, + undefined, + 11, + tidal, + ); expect(solution.placements.length).toBeGreaterThan(0); // Tile index by the island-local coordinates the report carries. @@ -761,16 +875,10 @@ describe("a terrain bonus reaching the board", () => { // Three tiles in a column against the board's left edge: all shore. const shore = layoutOn(["GRRRR", "GRRRR", "GRRRR"]); // The same column, walled in by rock and away from every edge. - const inland = layoutOn([ - "RRRRR", - "RRGRR", - "RRGRR", - "RRGRR", - "RRRRR", - ]); + const inland = layoutOn(["RRRRR", "RRGRR", "RRGRR", "RRGRR", "RRRRR"]); expect(inland).toBeGreaterThan(0); - expect(shore).toBeCloseTo(inland * 1.67, 6); + expect(shore).toBeCloseTo(inland * tidal.multiplier, 6); }); }); @@ -800,11 +908,13 @@ describe("role isolation for a role that is not the generator", () => { d: ROSTER[3], }; + const SINGULARITY = anomalyOfRule("singularity_isolation", "role_isolation"); + /** Singularity's own shape with the role swapped out, and nothing else. */ const isolating = ( role: EffectiveBuilding["type"], ): RoleIsolationAnomaly => ({ - ...(getAnomaly("singularity_isolation") as RoleIsolationAnomaly), + ...SINGULARITY, role, }); @@ -862,13 +972,13 @@ describe("role isolation for a role that is not the generator", () => { for (const key of ["0,0", "1,0"]) { const row = rated.get(key)!; expect(row.rated, `${key} touches another ${role}`).toBeCloseTo( - row.base * 0.8, + row.base * SINGULARITY.crowded, 6, ); } const lone = rated.get("4,0")!; expect(lone.rated, "three tiles from the nearest one").toBeCloseTo( - lone.base * 4, + lone.base * SINGULARITY.isolated, 6, ); @@ -1036,16 +1146,17 @@ describe("the search running under each rule", () => { it("threads a shared cooling pool into the layout it reports", async () => { /* * Pooling changes both halves of what a solve is worth: every cooler is - * x0.88 and adjacency stops mattering. Drop the anomaly on the way into the - * context and the search reports a figure about 13.6% over what the board is - * actually worth, arranged for a rule it is not under. + * rated by `coolerMultiplier` and adjacency stops mattering. Drop the + * anomaly on the way into the context and the search reports a figure about + * 1/`coolerMultiplier` of what the board is actually worth, arranged for a + * rule it is not under. * * The board is handed over whole, as `wholeBoardIsland` does — that is what a * pool is defined over. */ const cryo = getAnomaly("cryo_nexus"); - // A cooler tight against the generators' waste, so the x0.88 genuinely - // binds and the pool has work to do. + // A cooler tight against the generators' waste, so the pool's rating + // genuinely binds and the pool has work to do. const roster = basicRoster({ reactorValue: 500, coolerValue: 30 }); const [island] = splitGridIntoIslands( makeGrid(BOARD), @@ -1106,12 +1217,13 @@ describe("the search running under each rule", () => { * Grid at x2 on the generator and Absolute Zero at x2 on the cooler, and then * a shore bonus on top of both. */ - const tidal = getAnomaly("tidal_ascendancy"); + const tidal = getAnomaly("tidal_ascendancy_legacy"); const { buildings, unlocks } = basicCatalogue(); const roster = getEffectiveBuildings( buildings, unlocks, - prestigeScales({ infinite_grid: 4, absolute_zero: 4 }), + // The top level of each, x2. + prestigeScales({ infinite_grid: 9, absolute_zero: 9 }), ); expect( roster.map((b) => b.effectiveValue), @@ -1142,20 +1254,22 @@ describe("the search running under each rule", () => { * runtime getter, which is the order these two layers happen to be in — * `getEffectiveBuildings` folds research into the roster and `ctx.rate` * scales what comes out — and it is not the same double as the other way - * round: generator7's fourth tier under Infinite Grid at level 2 and then a - * Tidal shore is 1.8474375e22, against 1.8474374999999997e22 reversed. + * round: generator7's fourth tier under Infinite Grid at level 3 (x1.3) and + * then a Tidal shore is 1.921335e22, against 1.9213349999999996e22 + * reversed. Level 3 because it is the lowest whose factor rounds apart + * from x1.67 in the two orders; levels 1 and 2 agree to the last bit. * * `scaling.test.ts` pins the arithmetic; this pins that the production path * is that way round, which no test reached — `prestige.test.ts` never builds * a context, and every context test starts from an unresearched roster. */ - const tidal = getAnomaly("tidal_ascendancy"); + const tidal = anomalyOfRule("tidal_ascendancy_legacy", "terrain_affinity"); const [researched] = getEffectiveBuildings( BUILDINGS, { generator7: 3 }, - prestigeScales({ infinite_grid: 1 }), + prestigeScales({ infinite_grid: 2 }), ); - expect(researched.effectiveValue).toBe(8.85e21 * 1.25); + expect(researched.effectiveValue).toBe(8.85e21 * 1.3); expect(researched.baseValue, "the tier it identifies as").toBe(8.85e21); // Bare grass, so every tile of it is on the board's edge and therefore @@ -1163,10 +1277,11 @@ describe("the search running under each rule", () => { const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, tidal); const onShore = ctx.rate(ctx.tiles[0], researched); - expect(onShore.effectiveValue).toBe(8.85e21 * 1.25 * 1.67); - expect(onShore.effectiveValue).toBe(1.8474375e22); + expect(onShore.effectiveValue).toBe(8.85e21 * 1.3 * tidal.multiplier); + // Pinned to the last bit, which is why this uses the frozen pre-nerf build. + expect(onShore.effectiveValue).toBe(1.921335e22); // The anomaly first and the research second, which is the wrong way round. - expect(onShore.effectiveValue).not.toBe(8.85e21 * 1.67 * 1.25); + expect(onShore.effectiveValue).not.toBe(8.85e21 * tidal.multiplier * 1.3); expect(onShore.baseValue).toBe(8.85e21); }); }); diff --git a/packages/solver/tests/blueprint.test.ts b/packages/solver/tests/blueprint.test.ts index 8501683..55e96f8 100755 --- a/packages/solver/tests/blueprint.test.ts +++ b/packages/solver/tests/blueprint.test.ts @@ -371,6 +371,21 @@ describe("the rules section", () => { expect(decoded.rules?.anomalyId).toBe("tidal_ascendancy"); }); + it("keeps the pre-nerf Tidal apart from the live one", async () => { + // Two builds of one anomaly under one name; a code that collapsed them + // would rate an old save's board at the new figure. + const code = await encodeBlueprint( + GRID, + [], + undefined, + blueprintRules("tidal_ascendancy_legacy", {}), + ); + expect((await payloadOf(code)).slice(7)).toEqual([0, 4, 0]); + expect((await decodeBlueprint(code)).rules?.anomalyId).toBe( + "tidal_ascendancy_legacy", + ); + }); + it("reads rules that sit after a real tier table", async () => { const placements: BlueprintPlacement[] = [ { x: 0, y: 0, buildingId: "cooler1" }, diff --git a/packages/solver/tests/formatters.test.ts b/packages/solver/tests/formatters.test.ts index facb9e2..d67d41a 100755 --- a/packages/solver/tests/formatters.test.ts +++ b/packages/solver/tests/formatters.test.ts @@ -12,6 +12,7 @@ import { SUFFIXES, formatNumber, formatNumberForFilename, + formatNumberFull, parseHugeNumber, } from "../src/utils/formatters"; import { expectClose } from "./helpers"; @@ -60,6 +61,43 @@ describe("a figure spelled for the screen", () => { }); }); +describe("a figure spelled in full", () => { + it("groups every digit and folds trailing zero groups into a suffix", () => { + expect(formatNumberFull(6.11210546e23)).toBe("611,210,546AA"); + expect(formatNumberFull(1.5e15)).toBe("1,500T"); + expect(formatNumberFull(2e6)).toBe("2M"); + }); + + it("keeps a zero group that has digits after it", () => { + expect(formatNumberFull(1000001)).toBe("1,000,001"); + expect(formatNumberFull(1234567)).toBe("1,234,567"); + }); + + it("prints only the digits the double carries, never its binary noise", () => { + // BigInt(6.11210546e23) is 611210546000000006291456: digits nobody measured. + expect(formatNumberFull(6.11210546e23)).not.toContain("6291456"); + // A full-precision figure keeps all seventeen digits it has. + expect(formatNumberFull(1.2345678901234567e30)).toBe( + "1,234,567,890,123,456,700T", + ); + }); + + it("keeps a fraction to two places, and folds nothing beside one", () => { + expect(formatNumberFull(1234.5)).toBe("1,234.5"); + expect(formatNumberFull(1000.25)).toBe("1,000.25"); + expect(formatNumberFull(12.3456)).toBe("12.35"); + }); + + it("matches formatNumber at the edges", () => { + expect(formatNumberFull(0)).toBe("0"); + expect(formatNumberFull(0.001)).toBe("0"); + expect(formatNumberFull(999)).toBe("999"); + expect(formatNumberFull(-2500)).toBe("-2,500"); + expect(formatNumberFull(Infinity)).toBe("∞"); + expect(formatNumberFull(NaN)).toBe("NaN"); + }); +}); + describe("reading a figure back", () => { it("parses suffix notation", () => { expectClose(parseHugeNumber("20K"), 20e3); diff --git a/packages/solver/tests/helpers.ts b/packages/solver/tests/helpers.ts index 7b68f78..0fbcf15 100644 --- a/packages/solver/tests/helpers.ts +++ b/packages/solver/tests/helpers.ts @@ -16,13 +16,34 @@ import { } from "../src/solver/constants"; import type { IslandContext } from "../src/solver/context"; import { makeGrid } from "../src/grid"; +import { getAnomaly } from "../src/data/anomalies"; import type { + AnomalyDefinition, + AnomalyId, BuildingDefinition, EffectiveBuilding, Placement, Tile, } from "../src/solver/types"; +// --- Anomalies ------------------------------------------------------------- + +/** + * An anomaly narrowed to its rule's shape, so a test can read its figures — + * `multiplier`, `coolerMultiplier`, `isolated`, `crowded` — off the table + * rather than restating them. Restated, a rebalance in `data/anomalies.ts` + * leaves a test asserting the old number. Throws on a mismatched rule rather + * than trusting a cast. + */ +export function anomalyOfRule( + id: AnomalyId, + rule: R, +): Extract { + const anomaly = getAnomaly(id); + if (anomaly.rule !== rule) throw new Error(`${id} is not a ${rule} rule`); + return anomaly as Extract; +} + // --- Building factories --------------------------------------------------- // Ids default to something descriptive so failures name the building. diff --git a/packages/solver/tests/island.test.ts b/packages/solver/tests/island.test.ts index 35faabe..38a7c39 100644 --- a/packages/solver/tests/island.test.ts +++ b/packages/solver/tests/island.test.ts @@ -41,6 +41,7 @@ import type { TerrainAffinityAnomaly, } from "../src/solver/types"; import { + anomalyOfRule, basicRoster, cooler, directProducer, @@ -305,7 +306,7 @@ describe("water adjacency", () => { * `padMinY` is 1, and the only water on the board sits on one side of it. * The mask is therefore asymmetric: read at the wrong origin it describes a * different neighbourhood, and under a terrain bonus that is the wrong tiles - * rated x1.67 with nothing failing. + * rated at its `multiplier` with nothing failing. */ const rows = [ "RRRRRRRR", @@ -543,7 +544,7 @@ describe("the theoretical max-power bound", () => { * mixed-board case, where the two-class bound has real work to do, is * `the two-class bound under a terrain bonus` below. */ - const tidal = getAnomaly("tidal_ascendancy"); + const tidal = getAnomaly("tidal_ascendancy_legacy"); const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); const island = islandFor(["GGG", "GGG", "GGG"], roster); const bound = estimateTotalMaxPower([island], roster, tidal); @@ -576,9 +577,9 @@ describe("the theoretical max-power bound", () => { /* * The case a random-layout test cannot find, and the one that shipped * broken. `role_isolation` scales an isolated generator's heat *intake* by - * 4, so wherever generator intake is the short side of + * `isolated`, so wherever generator intake is the short side of * `min(nReact * rVal, nGen * gVal)` a layout that keeps its generators apart - * absorbs up to 4x the heat the bound allowed. Random placement almost + * absorbs up to `isolated` times the heat the bound allowed. Random placement almost * never isolates four generators, so this layout is built by hand: * * G R G four generators on the corners, which on a 3x3 are @@ -635,7 +636,7 @@ describe("the theoretical max-power bound", () => { * * It is homogeneous in the roster, so no rule that scales everything at * once can make it bite. A rule that scales a single role is exactly what - * it takes, and `role_isolation` is that rule: rating a lone generator x4 + * it takes, and `role_isolation` is that rule: rating a lone generator by `isolated` * while leaving the reactors that fill it alone let the LP spend fewer * tiles on generators than eight neighbours apiece can serve, and the * shipped maps' bound sat ~5% above anything the board allows. @@ -675,7 +676,7 @@ describe("the theoretical max-power bound", () => { * A generator authored at 4000 and one rated there by the anomaly are * the same building to the cap, so the plain bound on the first is the * anomaly's bound on the second. Which is also why the anomaly buys so - * much less than its x4 suggests: past eight reactors it buys nothing. + * much less than its `isolated` suggests: past eight reactors it buys nothing. */ const island = islandFor(board, basicRoster()); @@ -901,15 +902,15 @@ describe("the theoretical max-power bound", () => { * Pooling is the rule most likely to walk past a bound that assumed * adjacency — a cooler on the far side of the board now cools everything — * and the bound is safe because it never assumed any: it relaxes adjacency - * away and asks only what the tile counts allow. The 0.88 on every cooler + * away and asks only what the tile counts allow. The pool's `coolerMultiplier` * goes into the roster the bound is run on, so it is the plain bound over a - * roster whose coolers are worth 0.88 of their tier — below the plain one, - * never above it. + * roster whose coolers are worth that share of their tier — below the plain + * one, never above it. * * The board is handed over whole here, as `wholeBoardIsland` does, because * that is what the pool is defined over. */ - const cryo = getAnomaly("cryo_nexus"); + const cryo = anomalyOfRule("cryo_nexus", "shared_cooling"); const roster = basicRoster({ dpValue: 120, dpWasteRatio: 0.2 }); const [island] = splitGridIntoIslands( makeGrid(["GGG", "GGG", "GGG"]), @@ -929,7 +930,9 @@ describe("the theoretical max-power bound", () => { boundFor( island, roster.map((b) => - b.type === "cooler" ? scaleEffectiveBuilding(b, 0.88) : b, + b.type === "cooler" + ? scaleEffectiveBuilding(b, cryo.coolerMultiplier) + : b, ), ), ); @@ -955,7 +958,7 @@ describe("the theoretical max-power bound", () => { }); it("rises with a terrain bonus only where a tile qualifies", () => { - const tidal = getAnomaly("tidal_ascendancy"); + const tidal = anomalyOfRule("tidal_ascendancy_legacy", "terrain_affinity"); const roster = basicRoster(); // Two rows of bare grass: every tile is on the board's edge, so the whole // island is shore and the bound is the plain one scaled by the multiplier @@ -973,7 +976,7 @@ describe("the theoretical max-power bound", () => { expectClose( estimateTotalMaxPower([shore], roster, tidal), - estimateTotalMaxPower([shore], roster) * 1.67, + estimateTotalMaxPower([shore], roster) * tidal.multiplier, ); expectClose( estimateTotalMaxPower([inland], roster, tidal), @@ -992,13 +995,13 @@ describe("the theoretical max-power bound", () => { * is still a bound, that it is tighter, and that it collapses to the old * figure at either end. */ - const tidal = getAnomaly("tidal_ascendancy"); + const tidal = anomalyOfRule("tidal_ascendancy_legacy", "terrain_affinity"); /** The figure the two-class bound replaced: the plain LP at the multiplier. */ const wholeIslandFigure = ( island: IslandSubGrid, roster: EffectiveBuilding[], - ): number => estimateTotalMaxPower([island], roster) * 1.67; + ): number => estimateTotalMaxPower([island], roster) * tidal.multiplier; it("is never beaten by a random layout on a mixed board", () => { /* @@ -1169,7 +1172,7 @@ describe("the theoretical max-power bound", () => { // Tidal's own identity, because `AnomalyId` is a closed union and this is // a shape a future entry could take rather than one that ships. Only the // terrain list is the point. - ...(getAnomaly("tidal_ascendancy") as TerrainAffinityAnomaly), + ...(getAnomaly("tidal_ascendancy_legacy") as TerrainAffinityAnomaly), terrain: ["water", "rock"], multiplier: 2, }; diff --git a/packages/solver/tests/pipeline.test.ts b/packages/solver/tests/pipeline.test.ts index 5287898..ffa2447 100644 --- a/packages/solver/tests/pipeline.test.ts +++ b/packages/solver/tests/pipeline.test.ts @@ -30,7 +30,7 @@ import { splitGridIntoIslands, } from "../src/solver/island"; import { simulateIsland } from "../src/solver/simulate"; -import { solve } from "../src/solver/solver"; +import { islandBudgetsS, solve } from "../src/solver/solver"; import type { Placement, Tile } from "../src/solver/types"; import { basicCatalogue, basicRoster, expectClose } from "./helpers"; @@ -259,3 +259,41 @@ describe("solve quality", () => { ).toBeGreaterThanOrEqual(quick * 0.9); }, 20_000); }); + +describe("splitting the budget between islands", () => { + /* + * A scrap stops gaining long before its proportional share runs out and a + * main landmass is still climbing at double its share, so shares grow with + * tile count squared — with a floor that keeps a scrap's search finishing. + */ + const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0); + + it("always spends exactly the budget", () => { + for (const counts of [[180, 20, 6, 9, 4, 4], [165, 2, 79, 5], [1], [3, 3]]) + expectClose(sum(islandBudgetsS(counts, 15)), 15, 1e-12); + }); + + it("weights by tile count squared", () => { + expect(islandBudgetsS([30, 10], 30)).toEqual([27, 3]); + expect(islandBudgetsS([4, 4], 10)).toEqual([5, 5]); + }); + + it("holds a scrap at a quarter of a second rather than starving it", () => { + const [main, a, b] = islandBudgetsS([180, 4, 4], 15); + expect(a).toBe(0.25); + expect(b).toBe(0.25); + expect(main).toBe(14.5); + }); + + it("never raises a scrap above its proportional share to meet the floor", () => { + // 1s over 100 tiles: a 2-tile scrap's linear share is 0.02s, under the + // floor, and that share is what it keeps. + const [, scrap] = islandBudgetsS([98, 2], 1); + expectClose(scrap, 0.02, 1e-12); + }); + + it("spends nothing on a board with nothing to search", () => { + expect(islandBudgetsS([0], 30)).toEqual([0]); + expect(islandBudgetsS([], 30)).toEqual([]); + }); +}); diff --git a/packages/solver/tests/placementSearch.rating.test.ts b/packages/solver/tests/placementSearch.rating.test.ts index c1a2a78..ecab776 100644 --- a/packages/solver/tests/placementSearch.rating.test.ts +++ b/packages/solver/tests/placementSearch.rating.test.ts @@ -29,7 +29,13 @@ import { import { Pacer } from "../src/solver/pacer"; import { downgradeOversized, internals } from "../src/solver/placementSearch"; import { simulateIsland } from "../src/solver/simulate"; -import { generator, reactor, cooler, basicRoster } from "./helpers"; +import { + anomalyOfRule, + generator, + reactor, + cooler, + basicRoster, +} from "./helpers"; import type { EffectiveBuilding, Placement } from "../src/solver/types"; vi.mock("../src/solver/simulate", async (importOriginal) => { @@ -37,8 +43,10 @@ vi.mock("../src/solver/simulate", async (importOriginal) => { return { ...real, simulateIsland: vi.fn(real.simulateIsland) }; }); -const tidal = getAnomaly("tidal_ascendancy"); -const singularity = getAnomaly("singularity_isolation"); +// The pre-nerf build, whose figure is frozen: the worked numbers below (a shore +// reactor at 167) are 100 at its multiplier. +const tidal = anomalyOfRule("tidal_ascendancy_legacy", "terrain_affinity"); +const singularity = anomalyOfRule("singularity_isolation", "role_isolation"); /** * Two hubs' worth of grass, one on the board's edge and one walled in by rock, @@ -133,10 +141,10 @@ describe("what to build, counted in the layout's units", () => { * The same mistake as the rest of this file, made by the one stage that * decides *what* to build rather than where to put it. `targetCompositions` * counts, and counting needs to know what a generator is worth — which under - * `role_isolation` is two numbers, x4 with no generator beside it and x0.8 - * with. Which a tile gets is the layout's business, so neither is in the pool - * the stage draws on, and the count it produced was the one for a roster - * nobody is playing. + * `role_isolation` is two numbers, `isolated` with no generator beside it and + * `crowded` with. Which a tile gets is the layout's business, so neither is in + * the pool the stage draws on, and the count it produced was the one for a + * roster nobody is playing. * * Sizing at the bonus alone is not the fix either: it asks Gale Hills at * generator7 tier 1 for 20 generators on an island that can keep 15 apart, @@ -184,7 +192,7 @@ describe("what to build, counted in the layout's units", () => { const table = generatorCapacityTable(island, ctx, G)!; // Four generators on a 4x4 can all stand clear of each other... - expect(table[4]).toBe(4 * 40 * 4); + expect(table[4]).toBe(4 * 40 * singularity.isolated); // ...and a fifth cannot be put anywhere that is not beside one of them, so // it costs one of the four its bonus rather than adding to them. expect(table[5]).toBeLessThan(table[4]); @@ -222,7 +230,7 @@ describe("the island's rating ceiling", () => { it("is exactly one under the rules that leave the roster alone", () => { for (const anomaly of [getAnomaly("none"), getAnomaly("cryo_nexus")]) { const ctx = buildIslandContext(makeGrid(["GGG"]), undefined, anomaly); - // Cryo's 0.88 only ever costs cooling, so it cannot lift a ceiling. + // Cryo's `coolerMultiplier` only ever costs cooling, so it cannot lift a ceiling. expect(islandRatingCeiling(ctx, probes, false), anomaly.id).toBe(1); } }); @@ -234,7 +242,7 @@ describe("the island's rating ceiling", () => { probes, false, ), - ).toBeCloseTo(1.67, 9); + ).toBeCloseTo(tidal.multiplier, 9); // ...and one where none of it does: walled in, well away from the board. expect( islandRatingCeiling( @@ -393,12 +401,13 @@ describe("right-sizing a layout whose ratings come from its own shape", () => { it("hands back the capacity an isolated generator never uses", () => { /* - * A lone generator is rated x4, so the authored 320 tile is running at 1280 - * and absorbing 300 of it. The smallest tier that covers that load is the - * authored 120 — rated 480 — and at the same power, because a generator's - * energy scales with how full it is: 0.75 x 1280 x 300/1280 is 0.75 x 480 x - * 300/480. Compared in the roster's units instead, 120 does not cover 300 and - * the pass leaves 980 of intake the player paid for and nothing uses. + * A lone generator is rated by `isolated` — 1280 for the authored 320 tile + * at the shipped figure — and absorbing 300 of it. The smallest tier that + * covers that load is the authored 120 — rated 480 — and at the same power, + * because a generator's energy scales with how full it is: 0.75 x 1280 x + * 300/1280 is 0.75 x 480 x 300/480. Compared in the roster's units instead, + * 120 does not cover 300 and the pass leaves 980 of intake the player paid + * for and nothing uses. * * The worked example one step up is NOT a downgrade and must not become one: * the same tile absorbing 700 keeps the 320, since 120 rated 480 cannot carry @@ -435,11 +444,12 @@ describe("right-sizing a layout whose ratings come from its own shape", () => { it("leaves a crowded generator a tier that carries its penalised load", () => { /* - * The other direction. Two generators touching are rated x0.8, so a candidate - * that covers the load in the roster's units may not cover it at all once - * rated — and this pass is not allowed to cost power. The guard is the - * re-simulation, which is why nothing was ever WRONG here; the assertion is - * the property the pass sells, stated in the units the layout is rated in. + * The other direction. Two generators touching are rated by `crowded`, so a + * candidate that covers the load in the roster's units may not cover it at + * all once rated — and this pass is not allowed to cost power. The guard is + * the re-simulation, which is why nothing was ever WRONG here; the + * assertion is the property the pass sells, stated in the units the layout + * is rated in. */ const ctx = buildIslandContext( makeGrid(["GGGG", "GGGG"]), diff --git a/packages/solver/tests/prestige.test.ts b/packages/solver/tests/prestige.test.ts index e7974cd..98758aa 100644 --- a/packages/solver/tests/prestige.test.ts +++ b/packages/solver/tests/prestige.test.ts @@ -137,7 +137,8 @@ describe("research reaching the roster", () => { const roster = getEffectiveBuildings( DEFS, ALL, - prestigeScales({ stellar_forge: 4 }), + // The top level, x1.5. + prestigeScales({ stellar_forge: 9 }), ); expect(roster.find((b) => b.id === "r")!.effectiveValue).toBe(200 * 1.5); }); diff --git a/packages/solver/tests/scaling.test.ts b/packages/solver/tests/scaling.test.ts index 7fc9acb..3c6b0c6 100644 --- a/packages/solver/tests/scaling.test.ts +++ b/packages/solver/tests/scaling.test.ts @@ -16,6 +16,16 @@ import { scaleEffectiveBuilding } from "../src/data/effectiveBuildings"; import { snapToAuthoredPrecision } from "../src/solver/physics"; import type { EffectiveBuilding } from "../src/solver/types"; +/* + * The two anomaly factors every case below scales by. Literals on purpose, not + * read off `data/anomalies.ts`: each test pins a double to the last bit, and + * those bits belong to these numbers alone, so a rebalance must not move them. + * They are pre-nerf Tidal's shore bonus and Singularity's crowding penalty as + * the two shipped when this was written. + */ +const SHORE = 1.67; +const CROWDED = 0.8; + /** The authored tiers of the roster's top generator, the sharpest case here. */ const G7 = BUILDINGS.find((b) => b.id === "generator7")!; if (G7.type !== "generator") throw new Error("generator7 is not a generator"); @@ -35,21 +45,21 @@ const atTier = (tier: number): EffectiveBuilding => { describe("waste is derived from the scaled pair, not scaled itself", () => { it("differs from the scaled authored waste, and takes the derived value", () => { - // generator7 at tier 4 under Tidal Ascendancy. The game's runtime getter is + // generator7 at tier 4 on a `SHORE` tile. The game's runtime getter is // `(HeatPerTick - EnergyPerTick).SnapToAuthoredPrecision()` over the scaled // pair, so this is 3.6907e21 — carrying the authored 2.21e21 through the // same multiply gives 3.6906999999999996e21, half a million short. - const scaled = scaleEffectiveBuilding(atTier(3), 1.67); + const scaled = scaleEffectiveBuilding(atTier(3), SHORE); expect(scaled.waste).toBe(3.6907e21); - expect(scaled.waste).not.toBe(2.21e21 * 1.67); + expect(scaled.waste).not.toBe(2.21e21 * SHORE); expect(scaled.waste).toBe( snapToAuthoredPrecision(scaled.effectiveValue - scaled.energy), ); }); it("holds for a producer the other way up, at single-digit magnitudes", () => { - // The wind turbine's 3.75/3/0.75 goes the opposite way at x0.8: derived is + // The wind turbine's 3.75/3/0.75 goes the opposite way at `CROWDED`: derived is // exactly 0.6, scaled authored is 0.6000000000000001. Both directions have // to be wrong for the identity to be an accident. const turbine = BUILDINGS.find((b) => b.id === "wind_turbine")!; @@ -65,11 +75,11 @@ describe("waste is derived from the scaled pair, not scaled itself", () => { waste: level.waste!, baseValue: level.heat, }, - 0.8, + CROWDED, ); expect(scaled.waste).toBe(0.6); - expect(scaled.waste).not.toBe(0.75 * 0.8); + expect(scaled.waste).not.toBe(0.75 * CROWDED); }); it("leaves a cooler's and a reactor's waste at zero", () => { @@ -110,42 +120,42 @@ describe("two multipliers compose as two calls, not as one product", () => { // The game multiplies the authored figure by the Time Lab bonus in the SO // getter and by the anomaly in the runtime getter, so what a building is // rated at is `(authored x research) x anomaly`. That is not the same - // double as `authored x (research x anomaly)`: Infinite Grid at level 4 - // (x1.7) and a crowded generator under Singularity Isolation (x0.8) over + // double as `authored x (research x anomaly)`: Infinite Grid at level 7 + // (x1.7) and a `CROWDED` generator under Singularity Isolation over // generator7's fourth tier give 1.2036e22 one way and 1.2036000000000001e22 // the other. const researched = scaleEffectiveBuilding(atTier(3), 1.7); - const both = scaleEffectiveBuilding(researched, 0.8); + const both = scaleEffectiveBuilding(researched, CROWDED); - expect(both.effectiveValue).toBe(8.85e21 * 1.7 * 0.8); + expect(both.effectiveValue).toBe(8.85e21 * 1.7 * CROWDED); expect(both.effectiveValue).toBe(1.2036e22); - expect(both.effectiveValue).not.toBe(8.85e21 * (1.7 * 0.8)); + expect(both.effectiveValue).not.toBe(8.85e21 * (1.7 * CROWDED)); }); it("applies them in that order, research first, anomaly second", () => { // Which of the two calls comes first is itself observable, and the factors - // above cannot see it: 1.7 x 0.8 and 0.8 x 1.7 land on the same double. The + // above cannot see it: 1.7 x `CROWDED` and the reverse land on the same double. The // game's order is the Time Lab in the SO getter and the anomaly in the // runtime getter — research first — and on generator7's fourth tier under - // Infinite Grid at level 2 (x1.25) then a Tidal shore (x1.67) the two orders - // differ in the last bit: 1.8474375e22 against 1.8474374999999997e22. + // Infinite Grid at level 3 (x1.3) then a `SHORE` tile the two orders + // differ in the last bit: 1.921335e22 against 1.9213349999999996e22. // // Nothing above this line in the stack would notice: the roster resolves // research into its three figures and the context rates the result, so // reversing the two produces a board rated a few ULPs off — and the golden // fixtures pass no anomaly, so they cannot see it either. const researchFirst = scaleEffectiveBuilding( - scaleEffectiveBuilding(atTier(3), 1.25), - 1.67, + scaleEffectiveBuilding(atTier(3), 1.3), + SHORE, ); const anomalyFirst = scaleEffectiveBuilding( - scaleEffectiveBuilding(atTier(3), 1.67), - 1.25, + scaleEffectiveBuilding(atTier(3), SHORE), + 1.3, ); - expect(researchFirst.effectiveValue).toBe(8.85e21 * 1.25 * 1.67); - expect(researchFirst.effectiveValue).toBe(1.8474375e22); - expect(anomalyFirst.effectiveValue).toBe(1.8474374999999997e22); + expect(researchFirst.effectiveValue).toBe(8.85e21 * 1.3 * SHORE); + expect(researchFirst.effectiveValue).toBe(1.921335e22); + expect(anomalyFirst.effectiveValue).toBe(1.9213349999999996e22); expect(researchFirst.effectiveValue).not.toBe(anomalyFirst.effectiveValue); }); @@ -157,23 +167,23 @@ describe("two multipliers compose as two calls, not as one product", () => { * * The tier and the factors are chosen so the three readings actually part * company, which most pairs do not: generator7's third tier (4.43e21 / - * 3.32e21) under Infinite Grid at level 1 (x1.1) and then a x0.8 penalty + * 3.32e21) under Infinite Grid at level 1 (x1.1) and then a `CROWDED` penalty * gives 9.76800000000001e20 derived against 9.768e20 either other way. The * assertion against the identity alone is how `scaleEffectiveBuilding` * computes waste, so it holds for any deriving implementation and cannot * fail on its own. */ const once = scaleEffectiveBuilding(atTier(2), 1.1); - const both = scaleEffectiveBuilding(once, 0.8); + const both = scaleEffectiveBuilding(once, CROWDED); expect(both.waste).toBe(9.76800000000001e20); expect(both.waste).toBe( snapToAuthoredPrecision(both.effectiveValue - both.energy), ); // The once-derived waste carried through the second multiply... - expect(both.waste).not.toBe(snapToAuthoredPrecision(once.waste * 0.8)); + expect(both.waste).not.toBe(snapToAuthoredPrecision(once.waste * CROWDED)); // ...and the authored waste scaled by both factors. Both are 9.768e20. - expect(both.waste).not.toBe(1.11e21 * 1.1 * 0.8); + expect(both.waste).not.toBe(1.11e21 * 1.1 * CROWDED); }); }); @@ -182,7 +192,7 @@ describe("what a multiplier must not touch", () => { // It identifies the tier, and a tier does not change because something // scaled what it is worth. `effectiveAtValue` matches the catalogue on this // number, so a scaled one resolves to a higher tier and scales twice. - const scaled = scaleEffectiveBuilding(atTier(2), 1.67); + const scaled = scaleEffectiveBuilding(atTier(2), SHORE); expect(scaled.baseValue).toBe(4.43e21); expect(scaled.effectiveValue).not.toBe(scaled.baseValue); diff --git a/src/CLAUDE.md b/src/CLAUDE.md index dd3dc7e..1948b2e 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -78,7 +78,7 @@ drifts to twelve-odd hues — a Share button wearing the colour the board uses f | | means | | --------------- | ----------------------------------------------------------- | | `--accent` | this control is selected / active. Nothing else. | -| `--action` | the one *filled* primary control. RUN, and nothing else. | +| `--action` | the one _filled_ primary control. RUN, and nothing else. | | `--status-ok` | the board only: this building is working. | | `--status-idle` | the board only: this building is doing nothing. | | `--danger` | the board: overheating. In the UI: this destroys something. | @@ -137,7 +137,7 @@ of them carries a rule about anomalies at all. It replaced eleven copies of the signal — ten components each with `class:anomalous={configState.hasAnomaly}` and a `.anomalous` rule re-pointing -*a different subset* of the same tokens, which is exactly how the HUD's ground +_a different subset_ of the same tokens, which is exactly how the HUD's ground went purple while the selected tool on it stayed cyan, and how `BuildingPalette` came to remember `--text-dim` where `ObstaclePalette` beside it did not. Every new panel was another place to remember, and forgetting was @@ -154,7 +154,7 @@ Four things about what a theme may and may not move: - **Alpha is the component's, hue is the theme's.** Each surface picks its own strength over the board — the sheet 0.97, the readout 0.94, the HUD's pills 0.92, the docked panel 0.88 — by composing `rgba(var(--surface-panel-rgb), - …)`. That is why the ground is a bare triplet rather than a colour: one +…)`. That is why the ground is a bare triplet rather than a colour: one decision, not one token per surface. Selecting an anomaly changes the hue and nothing about how much board shows through. - **The whole `--accent` family goes**, so every mark meaning "selected" follows @@ -165,8 +165,8 @@ Four things about what a theme may and may not move: by contrast, and the badge purple reads 3.3:1 on this panel, unreadable as a heading. `#c9a5f0` puts back the 6.5:1 the cyan had. - **`--action` is a separate role from `--accent`, and this is what it is for.** - RUN is the one *filled* control in the app: a fill carrying ink, where - brighter is *less* legible, against `--accent`'s stroke-on-dark, where brighter + RUN is the one _filled_ control in the app: a fill carrying ink, where + brighter is _less_ legible, against `--accent`'s stroke-on-dark, where brighter is more. The base theme answers both with the same cyan, which is why they looked like one token until a theme needed them apart — the selection lavender carries neither ink at AA, so `--action` is the badge purple pulled diff --git a/src/lib/components/header/GridControls.svelte b/src/lib/components/header/GridControls.svelte index 5da5a9e..59c58cf 100755 --- a/src/lib/components/header/GridControls.svelte +++ b/src/lib/components/header/GridControls.svelte @@ -89,10 +89,7 @@ {/snippet} -
+
{@render stepper("Width", layoutState.width, setWidth)} {@render stepper("Height", layoutState.height, setHeight)}
diff --git a/src/lib/components/header/Header.svelte b/src/lib/components/header/Header.svelte index bf3d440..3195b96 100755 --- a/src/lib/components/header/Header.svelte +++ b/src/lib/components/header/Header.svelte @@ -1,9 +1,5 @@ + + + +
+ {label} + {formatNumberFull(value)} +
+ + diff --git a/src/lib/components/modals/ImportModal.svelte b/src/lib/components/modals/ImportModal.svelte index 18638b5..330c436 100755 --- a/src/lib/components/modals/ImportModal.svelte +++ b/src/lib/components/modals/ImportModal.svelte @@ -45,6 +45,7 @@ autocomplete="off" {...{ autocorrect: "off" }} aria-label="Share code" + data-autofocus disabled={atCapacity}> {#if atCapacity} diff --git a/src/lib/components/modals/ModalShell.svelte b/src/lib/components/modals/ModalShell.svelte index 3e3e86c..0a6ae5e 100755 --- a/src/lib/components/modals/ModalShell.svelte +++ b/src/lib/components/modals/ModalShell.svelte @@ -28,8 +28,14 @@ let closeEl = $state(null); $effect(() => { - // Focus lands on Close, the one control that is always safe to activate. - closeEl?.focus(); + // Focus lands on Close, the one control that is always safe to activate — + // unless the dialog marks the field it exists to fill (`data-autofocus`), + // as Import does its code box: a dialog that opens to one empty input + // should not make the user find it first. A disabled field falls back. + const wanted = panelEl?.querySelector( + "[data-autofocus]:not(:disabled)", + ); + (wanted ?? closeEl)?.focus(); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { diff --git a/src/lib/components/sidebar/AnomalySelector.svelte b/src/lib/components/sidebar/AnomalySelector.svelte index 6d9d2e5..2689cc4 100644 --- a/src/lib/components/sidebar/AnomalySelector.svelte +++ b/src/lib/components/sidebar/AnomalySelector.svelte @@ -44,6 +44,20 @@ * irrelevant. */ let disabled = $derived(solverState.isOptimizing); + + /* + * An older build of an anomaly (`variantOf`) is not a card: in the game it is + * the same anomaly, and a second Tidal card would read as a fifth rule. It is + * a switch on its parent's card instead, and the card shows whichever build + * is selected so the figures printed are the ones the solver runs on. + */ + const CARDS = ANOMALIES.filter((a) => !a.variantOf); + const variantsOf = (id: string) => { + const older = ANOMALIES.filter((a) => a.variantOf === id); + return older.length ? [ANOMALIES.find((a) => a.id === id)!, ...older] : []; + }; + const familyOf = (id: string) => + ANOMALIES.find((a) => a.id === id)?.variantOf ?? id;
@@ -61,52 +75,96 @@ same rules the game does.

- {#each ANOMALIES as anomaly (anomaly.id)} - {@const active = configState.anomalyId === anomaly.id} - - - {anomaly.benefit} - {anomaly.drawback} - + {#if active && variants.length} +
+ Bonus +
+ {#each variants as v (v.id)} + {@const on = configState.anomalyId === v.id} + + {/each} +
+
+ {/if} {#if active} {anomaly.description} {/if} - - +
+
{/each} @@ -150,19 +208,17 @@ border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; - cursor: pointer; transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); } - .anomaly-card:hover:not(:disabled) { + .anomaly-card:hover:not(.disabled) { border-color: var(--anomaly-selected-glow); } - .anomaly-card:disabled { + .anomaly-card.disabled { opacity: 0.5; - cursor: default; } /* @@ -212,6 +268,31 @@ padding: 0.6rem 0.7rem; } + /* The selecting half of the card: a bare button laid out as the column it + replaced, so the card reads exactly as it did when it was one button. */ + .card-hit { + display: flex; + flex-direction: column; + gap: inherit; + width: 100%; + margin: 0; + padding: 0; + font: inherit; + color: inherit; + text-align: left; + background: none; + border: 0; + cursor: pointer; + } + + .card-hit:disabled { + cursor: default; + } + + .anomaly-card.active .card-hit { + cursor: default; + } + .main-row { display: flex; align-items: center; @@ -333,8 +414,64 @@ padding-top: 0.5rem; } + /* + * The research cards' level chips (`PrestigeUpgrades`' `.tier-btn`), copied + * rather than shared: they sit one section up on this same tab and make the + * same kind of choice — which build of this thing your timeline holds. + * Wider than a level chip only because the label is a figure, not a digit. + */ + .variant-row { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .lbl { + font-size: var(--fs-2xs); + font-weight: 600; + text-transform: uppercase; + color: var(--text-dim); + } + + .variants { + display: flex; + gap: 3px; + } + + .variant { + height: 20px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: var(--radius-xs); + color: var(--text-dim); + font-size: var(--fs-2xs); + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: all var(--dur-fast) var(--ease); + } + + .variant:disabled { + cursor: default; + } + + .variant:not(:disabled):hover { + color: var(--text); + border-color: var(--accent-dim); + } + + .variant.on { + background: var(--accent-bg); + border-color: var(--accent); + color: var(--accent); + } + @media (pointer: coarse) { - .anomaly-card { + .card-hit { min-height: var(--tap); } } diff --git a/src/lib/components/sidebar/CLAUDE.md b/src/lib/components/sidebar/CLAUDE.md index d176137..01614cf 100644 --- a/src/lib/components/sidebar/CLAUDE.md +++ b/src/lib/components/sidebar/CLAUDE.md @@ -7,7 +7,7 @@ rules is in `src/lib/components/hud/CLAUDE.md`. `uiState.setupHidden` takes the panel off screen for the length of a run, and both ways back into it with it — the HUD's Setup button and the docked panel's -own handle. Everything on its three tabs is an input to *that* run (the island +own handle. Everything on its three tabs is an input to _that_ run (the island it is solving, the roster it was planned with, the timeline it is rating against) and a run reads every one of them once, at launch, so a press there either cannot reach the search at all or, on the island list, stops it outright. @@ -24,7 +24,7 @@ Three things follow, and each is something that would otherwise be left behind: otherwise.** Its teardown is the same call `HudToolbar` makes for `hudHeight` — a width left behind reserves canvas, and the HUD's own `padding-left` tracks the same edge — and that is right for a preview or a - hidden interface, which are states the user is *in*. A run is not: Setup is + hidden interface, which are states the user is _in_. A run is not: Setup is away for seconds and comes back on its own, so handing the space over means taking it again a moment later, with the action pill gliding 190px out from under the cursor that pressed RUN and back when it lands. Held, the whole @@ -52,7 +52,7 @@ from whether the switch is offered. Three reasons, all pointing the same way: the readout is pinned to the run for the duration (`statsPanel`), so switching to Edit leaves the card reporting the search while the canvas draws the user's board — the one disagreement `visiblePlacements` exists to make impossible; the -switch to the solver's board is an edge on the run *starting* (`PixiCanvas`), so +switch to the solver's board is an edge on the run _starting_ (`PixiCanvas`), so a layout finishing while the user is on their own board lands where nobody is looking; and on a first-ever run the pill would appear partway through anyway, the moment the first progress report gives it something to switch to, which on diff --git a/src/lib/components/sidebar/PrestigeUpgrades.svelte b/src/lib/components/sidebar/PrestigeUpgrades.svelte index 5889fa3..eaf6b26 100644 --- a/src/lib/components/sidebar/PrestigeUpgrades.svelte +++ b/src/lib/components/sidebar/PrestigeUpgrades.svelte @@ -276,8 +276,11 @@ color: var(--text-dim); } + /* Ten levels since the October 2026 update; on a narrow card they wrap + rather than pushing the card wider than the sheet. */ .tiers { display: flex; + flex-wrap: wrap; gap: 3px; } diff --git a/src/lib/simulation/simulator.test.ts b/src/lib/simulation/simulator.test.ts index 6b2c878..688efd9 100755 --- a/src/lib/simulation/simulator.test.ts +++ b/src/lib/simulation/simulator.test.ts @@ -24,7 +24,12 @@ */ import { describe, it, expect } from "vitest"; import { ratedPlacementAt, simulatePlacedBuildings } from "./simulator"; -import { getAnomaly } from "@reactor2/solver"; +import { + getAnomaly, + type RoleIsolationAnomaly, + type SharedCoolingAnomaly, + type TerrainAffinityAnomaly, +} from "@reactor2/solver"; import { buildIslandContext } from "@reactor2/solver"; import { splitGridIntoIslands } from "@reactor2/solver"; import { simulateIsland } from "@reactor2/solver"; @@ -360,7 +365,7 @@ describe("the readout rates a board under the same rules the search does", () => * solver's identical layout printed bonused ones — the two disagreeing about * the same board, which is the one thing this arrangement exists to prevent. */ - const tidal = getAnomaly("tidal_ascendancy"); + const tidal = getAnomaly("tidal_ascendancy_legacy") as TerrainAffinityAnomaly; /** A reactor, generator and cooler in a row, with their tiles. */ const chain = (x: number, y: number): Spec => [ @@ -403,7 +408,7 @@ describe("the readout rates a board under the same rules the search does", () => ); expect(powerOf(inland)).toBeGreaterThan(0); - expect(powerOf(shore)).toBeCloseTo(powerOf(inland) * 1.67, 6); + expect(powerOf(shore)).toBeCloseTo(powerOf(inland) * tidal.multiplier, 6); }); it("changes nothing when no anomaly is passed", () => { @@ -426,9 +431,11 @@ describe("the readout rates a board under the same rules the search does", () => }); describe("a rated ceiling is what the row beside it was measured against", () => { - const TIDAL = getAnomaly("tidal_ascendancy"); - const CRYO = getAnomaly("cryo_nexus"); - const SINGULARITY = getAnomaly("singularity_isolation"); + const TIDAL = getAnomaly("tidal_ascendancy_legacy") as TerrainAffinityAnomaly; + const CRYO = getAnomaly("cryo_nexus") as SharedCoolingAnomaly; + const SINGULARITY = getAnomaly( + "singularity_isolation", + ) as RoleIsolationAnomaly; /** A board walled in by rock, so no tile of it is on the board's edge. */ const inlandGrid = (w: number, h: number): Tile[][] => @@ -451,7 +458,7 @@ describe("a rated ceiling is what the row beside it was measured against", () => * with nothing failing anywhere. A shore cooler under Tidal Ascendancy cooled * 8.35 against a printed total of 8 — a used figure past the ceiling it was * measured against — while a Cryo Nexus cooler at full tilt reported 7.04 of - * a total of 8 it could never reach, because the 0.88 is in what the cooler + * a total of 8 it could never reach, because the pool's `coolerMultiplier` is in what the cooler * *is* and not in what the pool charges it. * * So every case here asserts the same two things: the ceiling carries the @@ -487,8 +494,14 @@ describe("a rated ceiling is what the row beside it was measured against", () => const cooler = scoreAndRate(grid, chain(0, 0), 2, 0, TIDAL); const reactor = scoreAndRate(grid, chain(0, 0), 0, 0, TIDAL); - expect(cooler.max.effectiveValue).toBeCloseTo(VALUES.cooler * 1.67, 6); - expect(reactor.max.effectiveValue).toBeCloseTo(VALUES.reactor * 1.67, 6); + expect(cooler.max.effectiveValue).toBeCloseTo( + VALUES.cooler * TIDAL.multiplier, + 6, + ); + expect(reactor.max.effectiveValue).toBeCloseTo( + VALUES.reactor * TIDAL.multiplier, + 6, + ); // The bug: measured against the plain roster, both of these were above 1. expect( @@ -511,8 +524,14 @@ describe("a rated ceiling is what the row beside it was measured against", () => const { rows, max } = scoreAndRate(grid, chain(0, 0), 1, 0, TIDAL); const generator = rowAt(rows, 1, 0); - expect(max.effectiveValue).toBeCloseTo(VALUES.generator * 1.67, 6); - expect(max.energy).toBeCloseTo(VALUES.generator * 0.75 * 1.67, 6); + expect(max.effectiveValue).toBeCloseTo( + VALUES.generator * TIDAL.multiplier, + 6, + ); + expect(max.energy).toBeCloseTo( + VALUES.generator * 0.75 * TIDAL.multiplier, + 6, + ); expect(generator.heatConsumed).toBeLessThanOrEqual(max.effectiveValue); expect(generator.powerGenerated).toBeLessThanOrEqual(max.energy); }); @@ -528,7 +547,10 @@ describe("a rated ceiling is what the row beside it was measured against", () => ]; const { rows, max } = scoreAndRate(grid, spec, 2, 0, CRYO); - expect(max.effectiveValue).toBeCloseTo(VALUES.cooler * 0.88, 6); + expect(max.effectiveValue).toBeCloseTo( + VALUES.cooler * CRYO.coolerMultiplier, + 6, + ); expect(rowAt(rows, 2, 0).coolingProvided).toBeCloseTo( max.effectiveValue, 6, @@ -540,7 +562,8 @@ describe("a rated ceiling is what the row beside it was measured against", () => * The one rule that cannot be answered by a tile: a generator's rating is a * function of what its neighbours *are*, so the ceiling for one tile is a * function of every other placement on the board. Two boards identical - * except for a second generator beside the first rate it x4 and x0.8. + * except for a second generator beside the first rate it at the rule's + * `isolated` and `crowded` respectively. */ const grid = inlandGrid(9, 3); const alone = scoreAndRate(grid, chain(2, 1), 3, 1, SINGULARITY); @@ -552,8 +575,14 @@ describe("a rated ceiling is what the row beside it was measured against", () => SINGULARITY, ); - expect(alone.max.effectiveValue).toBeCloseTo(VALUES.generator * 4, 6); - expect(crowded.max.effectiveValue).toBeCloseTo(VALUES.generator * 0.8, 6); + expect(alone.max.effectiveValue).toBeCloseTo( + VALUES.generator * SINGULARITY.isolated, + 6, + ); + expect(crowded.max.effectiveValue).toBeCloseTo( + VALUES.generator * SINGULARITY.crowded, + 6, + ); expect(rowAt(crowded.rows, 3, 1).heatConsumed).toBeLessThanOrEqual( crowded.max.effectiveValue, ); diff --git a/src/lib/simulation/simulator.ts b/src/lib/simulation/simulator.ts index 8b74fdf..3781430 100755 --- a/src/lib/simulation/simulator.ts +++ b/src/lib/simulation/simulator.ts @@ -56,10 +56,7 @@ function terrainSignature(grid: Tile[][]): number { return h; } -function contextFor( - grid: Tile[][], - anomaly: AnomalyDefinition, -): IslandContext { +function contextFor(grid: Tile[][], anomaly: AnomalyDefinition): IslandContext { const signature = terrainSignature(grid); if ( cached && diff --git a/src/lib/state/CLAUDE.md b/src/lib/state/CLAUDE.md index 1c9bca1..197fdb7 100644 --- a/src/lib/state/CLAUDE.md +++ b/src/lib/state/CLAUDE.md @@ -27,9 +27,10 @@ author's research, and a blueprint does not record what that was. - **Changing it re-scores both boards** through the same `$effect` in `App.svelte` a roster change goes through, and for the same reason. It is tracked separately there because the two ask for different work: a bought tier - changes which tier a *placement* resolves to (`rebasePlacements`), while an + changes which tier a _placement_ resolves to (`rebasePlacements`), while an anomaly changes none of them — the same building at the same tier is simply rated differently — so it needs the re-score alone. + ## The upgrade plan: the app side The search side is in `docs/SOLVER.md`; the readout's plan line in diff --git a/src/lib/state/layout.svelte.ts b/src/lib/state/layout.svelte.ts index cb45357..523632f 100755 --- a/src/lib/state/layout.svelte.ts +++ b/src/lib/state/layout.svelte.ts @@ -730,10 +730,17 @@ class LayoutState { * slot is the only destination that is never destructive. * * Throws with a message meant to be shown as-is. + * + * `onAccepted` runs once the code has been read and a slot claimed — the + * last moment before the board on screen is replaced — so a caller can stop + * work tied to the old board (a run) without losing it to a bad paste. A + * callback rather than a call of our own, because this store references + * nothing and the solver is not its to stop. */ async importBlueprint( code: string, unlockedUpgrades: Record, + onAccepted?: () => void, ): Promise { // `adoptPreview` clears the flag before calling this, which is what makes // taking a previewed board the one import that may run. Anything else @@ -781,6 +788,7 @@ class LayoutState { // takes, which is why importing needs no special case in load/save. this.savedGrids[island.id] = { code: trimmed }; this.#saveState(); + onAccepted?.(); await this.loadTemplate(island, unlockedUpgrades); return island; diff --git a/src/lib/state/ui.svelte.ts b/src/lib/state/ui.svelte.ts index 9510f53..25dffd5 100755 --- a/src/lib/state/ui.svelte.ts +++ b/src/lib/state/ui.svelte.ts @@ -1010,9 +1010,21 @@ class UIState { await layoutState.importBlueprint( this.importCode, configState.buildingUpgrades, + // A run belongs to the board it was launched from, which an import + // takes off the screen — the reason a template switch stops one first. + // Stopped only once the code is accepted, so a bad paste mid-run costs + // nothing. + () => solverState.stopOptimizer(), ); this.activeModal = null; this.importCode = ""; + // An import is a template switch, and every switch has to swap the + // solve panel to the new island's (it has none yet, so this clears it). + // Without it the previous island's solve stayed up — over the solver + // view that is the old board's buildings on the new board's terrain — + // until a switch away and back ran the restore this one skipped. + solverState.restore(); + this.closeInspector(); // The imported board is a different shape; frame it. this.recenterCanvas(); } catch (err) { diff --git a/src/lib/worker/solveModes.test.ts b/src/lib/worker/solveModes.test.ts index 4e8307e..eaacaa4 100755 --- a/src/lib/worker/solveModes.test.ts +++ b/src/lib/worker/solveModes.test.ts @@ -10,9 +10,10 @@ import { describe, expect, it } from "vitest"; import { SOLVE_MODES, estimateMakespanMs, taskDurationsMs } from "./solveModes"; describe("taskDurationsMs", () => { - it("splits an attempt's budget across islands by tile count", () => { + it("splits an attempt's budget across islands by tile count squared", () => { + // 900 : 100, where a linear split would give 3 : 1. See `islandBudgetsS`. const durations = taskDurationsMs(SOLVE_MODES.quick, [30, 10]); - expect(durations).toEqual([22_500, 7_500]); + expect(durations).toEqual([27_000, 3_000]); }); it("repeats the split once per attempt, attempt-major", () => { @@ -64,9 +65,10 @@ describe("estimateMakespanMs", () => { it("never reports less than the longest single task", () => { // An island that owns most of the board gets most of every attempt's - // budget, and no amount of parallelism makes that one search shorter. + // budget, and no amount of parallelism makes that one search shorter. The + // two scraps are held at the quarter-second floor, so it gets the rest. const durations = taskDurationsMs(SOLVE_MODES.quick, [90, 5, 5]); - expect(estimateMakespanMs(durations, 8)).toBe(27_000); + expect(estimateMakespanMs(durations, 8)).toBe(29_500); }); it("is zero when there is nothing to schedule", () => { diff --git a/src/lib/worker/solveModes.ts b/src/lib/worker/solveModes.ts index 87768cc..7cc614c 100755 --- a/src/lib/worker/solveModes.ts +++ b/src/lib/worker/solveModes.ts @@ -18,6 +18,8 @@ * the button is pressed rather than after. */ +import { islandBudgetsS } from "@reactor2/solver"; + export type SolveModeId = "quick" | "deep" | "max"; export interface SolveMode { @@ -118,18 +120,21 @@ export function estimateMakespanMs( * once before any of them is solved twice. * * Shared by the estimate and by the coordinator's own ordering, so the figure - * on the button cannot describe a schedule the run does not use. + * on the button cannot describe a schedule the run does not use. The split + * within an attempt is `islandBudgetsS`, the one `planSolve` hands the + * coordinator, for the same reason. */ export function taskDurationsMs( mode: SolveMode, islandTileCounts: readonly number[], ): number[] { - const total = islandTileCounts.reduce((a, b) => a + b, 0) || 1; + const perAttempt = islandBudgetsS( + islandTileCounts, + mode.attemptBudgetMs / 1000, + ).map((s) => s * 1000); const durations: number[] = []; for (let attempt = 0; attempt < mode.attempts; attempt++) { - for (const count of islandTileCounts) { - durations.push(mode.attemptBudgetMs * (count / total)); - } + durations.push(...perAttempt); } return durations; } diff --git a/src/lib/worker/solverCoordinator.ts b/src/lib/worker/solverCoordinator.ts index af68df7..e8a1e81 100755 --- a/src/lib/worker/solverCoordinator.ts +++ b/src/lib/worker/solverCoordinator.ts @@ -66,7 +66,7 @@ export function defaultPoolSize(): number { * Runs one island-search per worker, mirroring the process pool the reference * pipeline uses: islands cannot influence each other, so wall-clock time * becomes ~max(island budget) instead of the sum of them, and every island - * still gets its full proportional share of the budget. + * still gets its full share of the budget (`islandBudgetsS`). * * A multi-attempt run rides the same queue. Nothing about a second search of * an island differs from a first, so `attempts × islands` tasks go into the diff --git a/src/lib/worker/workerClient.ts b/src/lib/worker/workerClient.ts index 6ae51f2..0781e09 100755 --- a/src/lib/worker/workerClient.ts +++ b/src/lib/worker/workerClient.ts @@ -137,7 +137,7 @@ export class SolverWorkerClient { this.pending.set(id, entry); // The budget passed down is what *one attempt* gets: the coordinator hands - // each attempt the same proportional split across islands, so a run's total + // each attempt the same split across islands (`islandBudgetsS`), so a run's total // is attempts × this. const budgetMs = run?.attemptBudgetMs ?? this.options.maxDurationMs; const timeBudgetS = budgetMs ? budgetMs / 1000 : undefined;