diff --git a/CLAUDE.md b/CLAUDE.md index 089317e..1cdb418 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,18 @@ id. Both must reach `planSolve` through `SolveRunOptions`, or a run silently optimises for a roster the player does not have. The details are in `packages/solver/CLAUDE.md` (engine) and `src/lib/state/CLAUDE.md` (app). +### The upgrade plan + +Beside it sits the **upgrade plan** — on the Buildings tab, not this one, +since it is a statement about buildings: up to three tiers the player is about +to buy, in buying order. A run solves for the tiers at the end of it +while every layout it keeps also runs at today's tiers and after each step, and +comes back at today's tiers. It is not a game screen and not a rule change — +the roster is still the roster. The search side and the measurements behind +"ordered, at most three" are in `docs/SOLVER.md`; the app side in +`src/lib/state/CLAUDE.md`; the readout's plan line in +`src/lib/components/inspector/CLAUDE.md`. + ## Architecture ### Folder map diff --git a/README.md b/README.md index cd5f460..9df1361 100755 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ Workers so the board stays interactive while it thinks. it back. - **An unlock roster.** The solver only places what you have unlocked, at the tier you own, so its answer is a layout you can actually build. +- **Plan ahead for upgrades.** List up to three tiers you are about to buy, in + the order you will buy them, and a run solves for the last one while keeping + the layout running at today's tiers and after every step — so a purchase does + not overheat what you built, and you do not rebuild after each one. The + readout says what any board becomes once the plan is bought, and which step + would overheat it if one does. - **A solve is a shortlist, not a layout.** A search almost never finds a single best arrangement, so it keeps up to ten boards that tie on power and lets you cycle them and apply the one you like. Which tie is nicest to build is the one diff --git a/docs/SOLVER.md b/docs/SOLVER.md index 1af0344..9ad28e8 100644 --- a/docs/SOLVER.md +++ b/docs/SOLVER.md @@ -594,6 +594,49 @@ after them), the app's `worker/islandBest.ts` across attempts, and `chooseSolveV runs. A layout that _beats_ the shortlist is exempt, because it empties it first: the bar is on being a second answer, never on being an answer. +### Upgrade plans — a layout that survives the next few purchases + +`SolveOptions.upgradePlan` holds a layout to rosters besides the one it is scored at: the tiers +the player has today and each step on the way to the ones it is solved for, in buying order. +`resolveUpgradePlan` (`data/upgradePlan.ts`) turns an ordered list of `{ buildingId, level }` +purchases into exactly that pair — a `target` to pass as `unlockedUpgrades` and an `along` to +pass as the plan — skipping steps the roster has already caught up with, so a plan gets shorter +as it is carried out. + +**Why it exists** is that an upgrade can break a standing layout, and which way depends on the +role. Measured over the eight maps at 8s, two seeds and four progression stages: + +| Upgrade | Layout solved for today, after it | Layout solved for the target, today | +| --------- | ---------------------------------------------- | ----------------------------------- | +| Reactor | overheats every time (0–66% of its power left) | runs | +| Generator | often overheats | often overheats | +| Cooler | runs, 49–72% of a fresh target solve | overheats every time | + +A plan-held solve never overheated at any step in any run, and landed at 97–100% of the best +target power for generator upgrades and ~100% for reactor ones, at 57–98% of today's best — +the price of carrying cooling for heat that has not arrived. A cooler upgrade gains nothing: +the best layout that also runs today is today's. + +**The order is a path, not a set.** A layout that survives cooler-then-reactor can overheat if +the reactor comes first. Holding a layout to every order of three +1 upgrades (7 states) kept +31–59% of the best target power; a fixed order kept 59–99%. So the plan is ordered and the +player states the order. + +**How the search honours it.** `planHolds` simulates the layout at each plan roster, re-reading +every placement by building id, and every gate that admits a layout asks it beside the ordinary +stability test: the best record, repair, the composition swaps, pruning, right-sizing, the tie +shortlist and `stabilize`. Power is still measured at the target alone. Without a plan the call +returns at once, which is why the fixtures do not move. `upgradeSteps` rides the context in a +`WeakMap` rather than on `IslandContext`, so no stage's signature changed for it. + +**The first plan roster must be today's**, because the search seeds from it as well. Scored at +the target alone, the walk never adds a cooler only today needs — it never raises target power +— so a target-sized seed stripped by `stabilize` stays short of cooling for the whole run. On +cooler plans that took the result from 49% to 63–68% of the best target power; seeding from +both and keeping whichever runs better across the plan costs one extra seed construction. +Splitting the budget between a target-scored and a today-scored search was measured too: a few +points better on cooler plans, a few worse on reactor ones, so it was not kept. + --- ## The RNG contract diff --git a/packages/solver/CLAUDE.md b/packages/solver/CLAUDE.md index 2340c09..26f0d9c 100644 --- a/packages/solver/CLAUDE.md +++ b/packages/solver/CLAUDE.md @@ -624,6 +624,12 @@ _through_ unstable layouts but records only stable ones as best, and **"pruning never reduces power" is not a valid invariant**. The valid ones are "the returned layout is stable" and "pruning a _stable_ layout never reduces power". +**Under an upgrade plan (`SolveOptions.upgradePlan`) "stable" means stable at +every step.** Every gate that admits a layout also asks `planHolds`, and the +first plan roster must be today's, because the search seeds from it too — see +`docs/SOLVER.md`. Without a plan the check returns at once and the fixtures do +not move. + ### Verifying a solver change Start with `npm test`. Four parts of it are what actually catch a regression: diff --git a/packages/solver/src/data/upgradePlan.ts b/packages/solver/src/data/upgradePlan.ts new file mode 100644 index 0000000..fc85840 --- /dev/null +++ b/packages/solver/src/data/upgradePlan.ts @@ -0,0 +1,83 @@ +import type { BuildingDefinition } from "../solver/types"; + +/** + * One upgrade the player means to buy: a building, and the tier it goes to. + * + * `level` is a 0-based upgrade index, the same convention `unlockedUpgrades` + * uses, so the UI numbers it from 1 like every other tier button. + */ +export interface UpgradeStep { + buildingId: string; + level: number; +} + +/** + * How many upgrades a plan may hold. + * + * Every step is one more roster each kept layout has to be simulated under, and + * past a handful the answer stops being a layout for today and becomes a layout + * for a roster nobody holds yet. Three covers "the next few purchases", which + * is what the feature is for. + */ +export const MAX_UPGRADE_STEPS = 3; + +/** An upgrade plan reduced to the rosters a solve runs against. */ +export interface ResolvedUpgradePlan { + /** + * The roster after the last step that still upgrades something — what the + * search scores at. The player's own roster when no step does. + */ + target: Record; + /** + * Every roster before `target`, in buying order and starting with today's: + * what a layout also has to *run* under. Empty when no step upgrades + * anything, which is the same thing as having no plan. + */ + along: Record[]; + /** + * Per input step, whether it still raises a tier. A step stops counting once + * it is bought (the roster has caught up with it), when its building is + * locked, or when an earlier step already took the building that far. + */ + effective: boolean[]; +} + +/** + * Resolves an ordered upgrade plan against the player's roster. + * + * The order is the buying order, and it is the point: a layout that survives + * "cooler, then generator, then reactor" can overheat if the reactor comes + * first, since more heat arrives before the cooling for it. So the plan is a + * path, not a set, and each roster along it is the one before with one step + * applied. + * + * A step that no longer upgrades anything is skipped rather than refused. The + * usual reason is the best one — the player bought it — and the plan should + * simply get shorter as it is carried out, not turn into an error. + */ +export function resolveUpgradePlan( + buildings: readonly BuildingDefinition[], + unlockedUpgrades: Record, + steps: readonly UpgradeStep[], +): ResolvedUpgradePlan { + const along: Record[] = []; + const effective: boolean[] = []; + let current = unlockedUpgrades; + + for (const step of steps.slice(0, MAX_UPGRADE_STEPS)) { + const def = buildings.find((b) => b.id === step.buildingId); + const held = current[step.buildingId]; + const raises = + def !== undefined && + held !== undefined && + Number.isInteger(step.level) && + step.level > held && + step.level < def.levels.length; + effective.push(raises); + if (!raises) continue; + along.push(current); + current = { ...current, [step.buildingId]: step.level }; + } + + return { target: current, along, effective }; +} diff --git a/packages/solver/src/index.ts b/packages/solver/src/index.ts index a7d0e90..a543793 100644 --- a/packages/solver/src/index.ts +++ b/packages/solver/src/index.ts @@ -33,6 +33,7 @@ export * from "./data/buildings"; export * from "./data/effectiveBuildings"; export * from "./data/maps"; export * from "./data/prestige"; +export * from "./data/upgradePlan"; export * from "./encoding/blueprint"; export * from "./utils/formatters"; diff --git a/packages/solver/src/solver/placementSearch.ts b/packages/solver/src/solver/placementSearch.ts index 352cf82..50694e8 100755 --- a/packages/solver/src/solver/placementSearch.ts +++ b/packages/solver/src/solver/placementSearch.ts @@ -68,6 +68,13 @@ import type { * has to cool that share of the waste — and it is still the wrong answer, so * only stable layouts are ever recorded as best. "Pruning never reduces power" * is NOT a valid invariant here; "the returned layout is stable" is. + * + * **Under an upgrade plan "stable" means stable at every step.** The search + * scores at the plan's target, but every gate that admits a layout — the best + * record, repair, the composition swaps, pruning, right-sizing, the tie + * shortlist and `stabilize` itself — also asks `planHolds`, so a layout that + * overheats at any tier on the way is never kept. Without a plan that call + * returns at once and nothing here changes. */ const MAX_TOP_REACTOR_COPIES = 3; @@ -284,6 +291,59 @@ function anyOfflineProducer( return false; } +/** + * An upgrade plan: the rosters a layout has to stay stable under besides the + * one it is scored at — the tiers the player holds today and every step on the + * way to the target. Each step is keyed by building id, because a placement + * holds the target roster's objects and the step's own figures are found by + * id; a building the step does not have stands on its tile as nothing. + * + * A step is simulated with the rating its tiles give it (`ctx.rate`), so a + * terrain bonus or a cooling pool applies at every step exactly as it does at + * the target. + */ +interface UpgradePlan { + steps: Map[]; + /** Scratch the step layout is written into, one per island. */ + buffer: Placement; +} + +/** + * Held beside the context rather than on it, so the context type — and every + * stage that takes one — is untouched when there is no plan, which is always + * the case for the fixtures. + */ +const upgradePlans = new WeakMap(); + +/** + * The producers that go offline under the first plan step the layout fails, or + * null when it runs at every step. Free when the island has no plan. + */ +function planFailure( + placement: Placement, + ctx: IslandContext, +): number[] | null { + const plan = upgradePlans.get(ctx); + if (plan === undefined) return null; + const buffer = plan.buffer; + for (const step of plan.steps) { + for (let t = 0; t < ctx.n; t++) { + const b = placement[t]; + const at = b === null ? undefined : step.get(b.id); + buffer[t] = at === undefined ? null : ctx.rate(t, at); + } + const sim = simulateIsland(buffer, ctx); + const offline = offlineProducers(buffer, sim.placements); + if (offline.length > 0) return offline; + } + return null; +} + +/** Whether the layout runs at every step of the island's plan. */ +function planHolds(placement: Placement, ctx: IslandContext): boolean { + return planFailure(placement, ctx) === null; +} + interface StableLayout { placement: Placement; power: number; @@ -304,7 +364,10 @@ function stabilize(placement: Placement, ctx: IslandContext): StableLayout { let sim = simulateIsland(current, ctx); for (;;) { - const offline = offlineProducers(current, sim.placements); + let offline = offlineProducers(current, sim.placements); + // Stable at the target is not enough under a plan: a producer that + // overheats at any step goes too, and so on until every step runs. + if (offline.length === 0) offline = planFailure(current, ctx) ?? []; if (offline.length === 0) { return { placement: current, @@ -1075,14 +1138,15 @@ async function hillClimb( !collector.full && powerTies(power, collector.power) && collector.accepts(snapshot) && - !anyOfflineProducer(snapshot, rows) + !anyOfflineProducer(snapshot, rows) && + planHolds(snapshot, ctx) ) { collector.offer(power, snapshot); } return; } - if (!anyOfflineProducer(snapshot, rows)) { + if (!anyOfflineProducer(snapshot, rows) && planHolds(snapshot, ctx)) { bestPower = power; bestPlacement = snapshot.slice(); collector?.offer(power, snapshot); @@ -1720,7 +1784,8 @@ async function arrangeComposition( const trial = simulateIsland(current, ctx); if ( trial.totalPower - basePower > bestGain && - !anyOfflineProducer(current, trial.placements) + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) ) { bestGain = trial.totalPower - basePower; bestSwap = [a, b]; @@ -1793,7 +1858,7 @@ async function greedyPolish( let current = placement.slice(); const sim = simulateIsland(current, ctx); let bestPower = sim.totalPower; - if (anyOfflineProducer(current, sim.placements)) { + if (anyOfflineProducer(current, sim.placements) || !planHolds(current, ctx)) { const stabilized = stabilize(current, ctx); current = stabilized.placement; bestPower = stabilized.power; @@ -1823,7 +1888,8 @@ async function greedyPolish( const trial = simulateIsland(current, ctx); if ( trial.totalPower - bestPower > bestGain && - !anyOfflineProducer(current, trial.placements) + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) ) { bestGain = trial.totalPower - bestPower; bestTile = tile; @@ -1893,7 +1959,8 @@ function pruneDeadWeight( // generator on the board. if ( trial.totalPower >= bestPower - EPS && - !anyOfflineProducer(current, trial.placements) + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) ) { bestPower = trial.totalPower; rows = trial.placements; @@ -2091,7 +2158,8 @@ export function downgradeOversized( if ( covers(trial.totalPower, floor) && - !anyOfflineProducer(current, trial.placements) + !anyOfflineProducer(current, trial.placements) && + planHolds(current, ctx) ) { power = trial.totalPower; rows = trial.placements; @@ -2275,6 +2343,7 @@ export async function solveIsland( hooks?: SearchHooks, rngSeed?: number, anomaly?: AnomalyDefinition, + upgradeSteps?: EffectiveBuilding[][], ): Promise { const ctx = buildIslandContext( island.grid, @@ -2283,6 +2352,17 @@ export async function solveIsland( island.waterAdjacent, ); if (ctx.n === 0) return { placements: [], powerOutput: 0.0 }; + // The rosters the layout must also run under — see `UpgradePlan`. The search + // still scores at `effectiveBuildings`; these only narrow what it may keep. + // `upgradeSteps[0]` is today's roster, which the seed below relies on. + if (upgradeSteps !== undefined && upgradeSteps.length > 0) { + upgradePlans.set(ctx, { + steps: upgradeSteps.map( + (roster) => new Map(roster.map((b) => [b.id, b])), + ), + buffer: emptyPlacement(ctx.n), + }); + } // 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 @@ -2320,7 +2400,7 @@ export async function solveIsland( const timeBudgetMs = timeBudgetS * 1000; const deadlineMs = performance.now() + timeBudgetMs; - const seed = constructMultiStartSeed( + let seed = constructMultiStartSeed( ctx, reactors, generators, @@ -2329,6 +2409,40 @@ export async function solveIsland( deadlineMs, ); + // Under a plan, a seed built from the target roster is sized for buildings + // the player does not have yet: fewer coolers than today's tiers need, so + // stabilizing it strips it bare — and nothing downstream puts the cooling + // back, since a cooler that only today needs never raises target power. So + // seed from the first step (today's tiers) as well, written in target + // buildings, and start from whichever runs better across the plan. + const plan = upgradePlans.get(ctx); + if (plan !== undefined) { + const today = plan.steps[0]; + const byRole = (type: string, key: "effectiveValue" | "energy") => + [...today.values()] + .filter((b) => b.type === type) + .map(pool) + .sort((a, b) => b[key] - a[key]); + const todaySeed = constructMultiStartSeed( + ctx, + byRole("reactor", "effectiveValue"), + byRole("generator", "effectiveValue"), + byRole("cooler", "effectiveValue"), + byRole("direct_producer", "energy"), + deadlineMs, + ); + const targetById = new Map(effectiveBuildings.map((b) => [b.id, b])); + const remapped = emptyPlacement(ctx.n); + for (let t = 0; t < ctx.n; t++) { + const b = todaySeed[t]; + const target = b === null ? undefined : targetById.get(b.id); + if (target !== undefined) put(remapped, ctx, t, target); + } + if (stabilize(remapped, ctx).power > stabilize(seed, ctx).power) { + seed = remapped; + } + } + // Repair the seed before annealing, and again afterwards. Seed construction // lays out self-sufficient hubs, so it systematically misses layouts where // neighbouring hubs share a cooler or a reactor; repair finds those directly diff --git a/packages/solver/src/solver/solver.ts b/packages/solver/src/solver/solver.ts index 04498cf..8eed172 100755 --- a/packages/solver/src/solver/solver.ts +++ b/packages/solver/src/solver/solver.ts @@ -47,6 +47,12 @@ export interface IslandPlan { originalWidth: number; totalGrassTiles: number; theoreticalMaxPower: number; + /** + * The upgrade plan's rosters, resolved like `effectiveBuildings` and in the + * same order as `SolveOptions.upgradePlan` — today's first. Empty without a + * plan. + */ + upgradeSteps?: EffectiveBuilding[][]; } /** @@ -65,6 +71,7 @@ export function planSolve( rngSeed?: number, anomalyId?: string, prestige?: PrestigeScales, + upgradePlan?: Record[], ): IslandPlan | null { if (!grid || grid.length === 0 || !grid[0] || grid[0].length === 0) return null; @@ -119,6 +126,9 @@ export function planSolve( effectiveBuildings, anomaly, ), + upgradeSteps: (upgradePlan ?? []).map((unlocks) => + getEffectiveBuildings(buildings, unlocks, prestige), + ), }; } @@ -225,6 +235,7 @@ export async function solve( rngSeed, options?.anomalyId, options?.prestige, + options?.upgradePlan, ); if (!plan) return createEmptyResult(grid?.[0]?.length ? countGrassTiles(grid) : 0); @@ -249,6 +260,7 @@ export async function solve( }, plan.seeds[i], plan.anomaly, + plan.upgradeSteps, ); options?.onProgress?.(buildOptimizationResult(plan, islandResults)); } diff --git a/packages/solver/src/solver/types.ts b/packages/solver/src/solver/types.ts index 388df1c..bcd5416 100755 --- a/packages/solver/src/solver/types.ts +++ b/packages/solver/src/solver/types.ts @@ -446,6 +446,23 @@ export interface SolveOptions { * board, has to be sent instead of folded in. */ prestige?: PrestigeScales; + /** + * An upgrade plan: the rosters a layout must also *run* under, besides the + * one it is scored at. `unlockedUpgrades` is then the plan's target — the + * tiers after the last upgrade — and these are the player's tiers today + * followed by each step on the way there, in buying order. + * `resolveUpgradePlan` produces exactly this pair. + * + * **The first entry has to be today's roster**, because the search seeds + * from it as well as from the target: a seed sized for tiers nobody holds + * yet is short of today's cooling, and nothing later in the search adds a + * cooler that only today needs, since it never raises target power. + * + * Every layout the search keeps runs at every entry; power is still measured + * at the target alone. Omitted or empty means no plan, and then nothing about + * the search changes. + */ + upgradePlan?: Record[]; } // ============================================================================ @@ -464,6 +481,12 @@ export type IslandWorkerRequest = anomalyId?: AnomalyId; /** Pins the annealing walk's random stream; omitted means a fresh seed. */ rngSeed?: number; + /** + * The upgrade plan's rosters, already resolved — see + * `SolveOptions.upgradePlan`. Sent resolved, like `effectiveBuildings`, + * so the research folded into them crosses with them. + */ + upgradeSteps?: EffectiveBuilding[][]; } | { id: string; type: "STOP" }; diff --git a/packages/solver/tests/upgradePlan.test.ts b/packages/solver/tests/upgradePlan.test.ts new file mode 100644 index 0000000..b9e5445 --- /dev/null +++ b/packages/solver/tests/upgradePlan.test.ts @@ -0,0 +1,232 @@ +/** + * The upgrade plan: resolving an ordered list of purchases into rosters, and + * the search holding every layout it keeps to all of them. + * + * The search half is pinned as an invariant rather than a figure. What a plan + * promises is that the returned layout runs — no producer overheats — at + * today's tiers and after every step, and that is checkable exactly on any + * run; the power it reaches depends on the budget and is the fixtures' and the + * CLI's business. The rosters are ones where the promise costs something: a + * reactor upgrade that shuts down a layout solved for today, and a cooler + * upgrade that the target alone would build too few coolers for. + */ +import { describe, expect, it } from "vitest"; +import { BUILDINGS } from "../src/data/buildings"; +import { getEffectiveBuildings } from "../src/data/effectiveBuildings"; +import { BLANK_ISLAND_CODE } from "../src/data/maps"; +import { + MAX_UPGRADE_STEPS, + resolveUpgradePlan, + type UpgradeStep, +} from "../src/data/upgradePlan"; +import { decodeBlueprint } from "../src/encoding/blueprint"; +import { buildIslandContext } from "../src/solver/context"; +import { wasteIsCovered } from "../src/solver/physics"; +import { simulateIsland } from "../src/solver/simulate"; +import { planSolve, solve } from "../src/solver/solver"; +import type { + BuildingDefinition, + PlacedBuilding, + Placement, + Tile, +} from "../src/solver/types"; + +const CATALOGUE = BUILDINGS as BuildingDefinition[]; + +describe("resolveUpgradePlan", () => { + const roster = { generator4: 3, cooler4: 5, quantum_reactor: 1 }; + + it("walks the steps in order, each roster one purchase on from the last", () => { + const steps: UpgradeStep[] = [ + { buildingId: "cooler4", level: 6 }, + { buildingId: "generator4", level: 5 }, + { buildingId: "quantum_reactor", level: 2 }, + ]; + const plan = resolveUpgradePlan(CATALOGUE, roster, steps); + expect(plan.effective).toEqual([true, true, true]); + expect(plan.along).toEqual([ + roster, + { ...roster, cooler4: 6 }, + { ...roster, cooler4: 6, generator4: 5 }, + ]); + expect(plan.target).toEqual({ + generator4: 5, + cooler4: 6, + quantum_reactor: 2, + }); + }); + + it("skips a step the roster has caught up with, rather than refusing it", () => { + // Bought: the roster already holds generator4 at 5. + const plan = resolveUpgradePlan(CATALOGUE, { ...roster, generator4: 5 }, [ + { buildingId: "generator4", level: 5 }, + { buildingId: "cooler4", level: 7 }, + ]); + expect(plan.effective).toEqual([false, true]); + expect(plan.along).toHaveLength(1); + expect(plan.target.cooler4).toBe(7); + }); + + it("counts a building's earlier step, so the same tier twice is one step", () => { + const plan = resolveUpgradePlan(CATALOGUE, roster, [ + { buildingId: "generator4", level: 4 }, + { buildingId: "generator4", level: 4 }, + { buildingId: "generator4", level: 5 }, + ]); + expect(plan.effective).toEqual([true, false, true]); + expect(plan.target.generator4).toBe(5); + }); + + it("ignores a locked building and a level past the table", () => { + const plan = resolveUpgradePlan(CATALOGUE, roster, [ + { buildingId: "generator7", level: 1 }, + { buildingId: "generator4", level: 99 }, + { buildingId: "no_such_building", level: 1 }, + ]); + expect(plan.effective).toEqual([false, false, false]); + expect(plan.along).toEqual([]); + expect(plan.target).toBe(roster); + }); + + it(`reads no more than ${MAX_UPGRADE_STEPS} steps`, () => { + const plan = resolveUpgradePlan(CATALOGUE, { cooler6: 0 }, [ + { buildingId: "cooler6", level: 1 }, + { buildingId: "cooler6", level: 2 }, + { buildingId: "cooler6", level: 3 }, + { buildingId: "cooler6", level: 4 }, + ]); + expect(plan.effective).toHaveLength(MAX_UPGRADE_STEPS); + expect(plan.target.cooler6).toBe(3); + }); +}); + +/** Everything up to the named tiers at their top level, the named ones below it. */ +function roster( + top: Record, + alsoMaxed: string[], +): Record { + const out: Record = {}; + for (const id of alsoMaxed) { + const def = CATALOGUE.find((b) => b.id === id)!; + out[id] = def.levels.length - 1; + } + return { ...out, ...top }; +} + +/** Producers that overheat when the layout is rated at `unlocks`. */ +function overheatingAt( + grid: Tile[][], + placements: PlacedBuilding[], + unlocks: Record, +): number { + const byId = new Map( + getEffectiveBuildings(CATALOGUE, unlocks).map((b) => [b.id, b]), + ); + const ctx = buildIslandContext(grid); + const stride = Math.max(...ctx.xs) + 1; + const tileAt = new Map(); + for (let t = 0; t < ctx.n; t++) tileAt.set(ctx.ys[t] * stride + ctx.xs[t], t); + const placement: Placement = new Array(ctx.n).fill(null); + for (const p of placements) { + const t = tileAt.get(p.y * stride + p.x)!; + placement[t] = ctx.rate(t, byId.get(p.buildingId)!); + } + return simulateIsland(placement, ctx, true).placements.filter( + (row) => + row.wasteHeatGenerated > 0 && + !wasteIsCovered(row.wasteHeatGenerated, row.coolingReceived), + ).length; +} + +describe("solving under an upgrade plan", () => { + // The generator-4 era: everything below maxed, the top of each role mid-way. + const today = roster({ generator4: 3, cooler4: 5, quantum_reactor: 1 }, [ + "cooler1", + "cooler2", + "cooler3", + "generator", + "generator2", + "generator3", + "solar_panel", + "coal_plant", + "hydro_plant", + "gas_plant", + "heliothermal_plant", + "geothermal_plant", + "biomass_plant", + "nuclear_reactor", + "fusion_reactor", + "arc_reactor", + "antimatter_plant", + ]); + + const cases: [string, UpgradeStep[]][] = [ + // More heat, and nothing more to cool it with. + ["a reactor upgrade", [{ buildingId: "quantum_reactor", level: 3 }]], + // Fewer coolers do at the target than today. + ["a cooler upgrade", [{ buildingId: "cooler4", level: 7 }]], + [ + "three upgrades in order", + [ + { buildingId: "cooler4", level: 6 }, + { buildingId: "generator4", level: 4 }, + { buildingId: "quantum_reactor", level: 2 }, + ], + ], + ]; + + for (const [name, steps] of cases) { + it(`keeps the layout running at every step of ${name}`, async () => { + const { grid } = await decodeBlueprint(BLANK_ISLAND_CODE); + const plan = resolveUpgradePlan(CATALOGUE, today, steps); + expect(plan.along.length).toBe(steps.length); + + const result = await solve( + grid, + CATALOGUE, + plan.target, + 1.5, + { upgradePlan: plan.along }, + 7, + ); + + expect(result.totalPower).toBeGreaterThan(0); + for (const unlocks of [...plan.along, plan.target]) { + expect(overheatingAt(grid, result.placements, unlocks)).toBe(0); + } + }); + } + + it("treats an empty plan as no plan", async () => { + const { grid } = await decodeBlueprint(BLANK_ISLAND_CODE); + const plan = planSolve( + grid, + CATALOGUE, + today, + 1, + 0, + undefined, + undefined, + [], + ); + expect(plan?.upgradeSteps).toEqual([]); + }); + + it("resolves each step's roster the way the target's is resolved", async () => { + const { grid } = await decodeBlueprint(BLANK_ISLAND_CODE); + const steps = resolveUpgradePlan(CATALOGUE, today, cases[2][1]); + const plan = planSolve( + grid, + CATALOGUE, + steps.target, + 1, + 0, + undefined, + undefined, + steps.along, + )!; + expect(plan.upgradeSteps).toEqual( + steps.along.map((unlocks) => getEffectiveBuildings(CATALOGUE, unlocks)), + ); + }); +}); diff --git a/src/App.svelte b/src/App.svelte index 452d0ef..de4d51f 100755 --- a/src/App.svelte +++ b/src/App.svelte @@ -19,6 +19,7 @@ ShareModal, SolveModal, SolveModeInfoModal, + UpgradePlanModal, } from "./lib/components"; import { Eye } from "lucide-svelte"; @@ -320,6 +321,7 @@ + +
+ + {#if planFailure !== null}{/if} + After upgrades + + + + {formatNumber(planReport.power)} + energy + + {#if planFailure !== null} + + {planReport.overheating} + {plural(planReport.overheating)} + {planReport.overheating === 1 ? "overheats" : "overheat"} + at {planFailure} + + {/if} + +
+ {/if} + - {#if board.idle > 0} + {#if idle > 0}
Idle - {board.idle} {plural(board.idle)} + {idle} {plural(idle)} +
+ {/if} + {#if reserved > 0} + +
+ Kept for upgrades + {reserved} {plural(reserved)}
{/if} {#if board.overheating > 0} @@ -1244,6 +1306,28 @@ font-weight: 500; } + /* The label holds its line; the value side is what wraps. */ + .plan-row { + align-items: flex-start; + } + .plan-row .row-label { + white-space: nowrap; + } + + /* The figure, then — when a step fails — which one, under it. */ + .plan-value { + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: right; + } + + .plan-power { + display: flex; + align-items: center; + gap: 0.2rem; + } + /* ── The shortlist of tied layouts ─────────────────────────────── */ .variant-controls { display: flex; diff --git a/src/lib/components/inspector/CLAUDE.md b/src/lib/components/inspector/CLAUDE.md index 876e6d3..cbdcda1 100644 --- a/src/lib/components/inspector/CLAUDE.md +++ b/src/lib/components/inspector/CLAUDE.md @@ -71,6 +71,20 @@ construction" holds only for a layout the solver has _just_ returned, and behind a standing solve can leave it unstable, and the card has to say so rather than printing a power figure whose buildings have quietly shut down. +**Under an upgrade plan the card adds an "After upgrades" line** +(`uiState.planReport`): the power once the plan is bought, and — when a step +would overheat something — how many buildings and at which step, named by +building and level because the list it would index is behind a closed panel on +a phone. It is `--warn` rather than the board's red: it describes a board that +does not exist yet. It shows for any board on screen, so a solve found before +the plan existed is told the upgrade will shut part of it down. + +The same report counts **`reserved`** — buildings idle today that work once the +plan is bought, mostly coolers held for heat still to come. They come out of the +amber Idle row (and the folded pill's count) and are listed uncoloured as "Kept +for upgrades", because printed as idle they are exactly what a tidy player would +remove. The pads on the board still say idle, which is true today. + **Amber is idle, red is overheating, and that is the whole app's language rather than this card's.** The status pad under every building says the same in the same colours (`STATUS_FRAME` in `pixi/gridPainter.ts`), the building above it breathes diff --git a/src/lib/components/modals/UpgradePlanModal.svelte b/src/lib/components/modals/UpgradePlanModal.svelte new file mode 100644 index 0000000..a398e44 --- /dev/null +++ b/src/lib/components/modals/UpgradePlanModal.svelte @@ -0,0 +1,590 @@ + + +{#if uiState.activeModal === "upgradePlan"} + uiState.closeModal()}> +

+ The upgrades you'll buy next, in order. A run then solves for the last one + while keeping the layout running at every step, so buying them never + overheats what you built. The board still shows today's tiers. +

+ + +

+ + + Only the last step is optimal. Until every upgrade is bought, + the layout makes less power than a normal solve would, because it keeps room + for what's coming. The readout shows power now and after the upgrades. + +

+ + {#if rows.length > 0} +
    + {#each rows as row, i (i)} +
  1. + + + + +
    +
    + {row.def?.name ?? row.step.buildingId} + + + + + +
    + + {#if row.effective && row.def && row.from !== undefined} +
    + Lv. {row.from + 1} → + + {#each levelsAbove(row.def, row.from) as lvl (lvl)} + + {/each} + +
    + {:else} + + Lv. {row.step.level + 1} · {idleReason( + row.def, + row.from, + row.step.level, + )} + + {/if} +
    +
  2. + {/each} +
+ {/if} + + {#if showPicker} +
+
+ {rows.length === 0 ? "Pick an upgrade" : "Add an upgrade"} + {#if rows.length > 0} + + {/if} +
+ + {#each candidates as group (group.label)} +
{group.label}
+
+ {#each group.buildings as b (b.id)} + {@const at = configState.resolvedPlan.target[b.id]} + {@const stat = statForType(b.type)} + + {/each} +
+ {:else} +

+ Nothing to plan: every unlocked building is at its top level. +

+ {/each} +
+ {:else if !full} + + {:else} +

+ A plan holds up to {MAX_UPGRADE_STEPS} upgrades. +

+ {/if} +
+{/if} + + diff --git a/src/lib/components/sidebar/BuildingUnlockCard.svelte b/src/lib/components/sidebar/BuildingUnlockCard.svelte index a0ecf44..bf718dc 100755 --- a/src/lib/components/sidebar/BuildingUnlockCard.svelte +++ b/src/lib/components/sidebar/BuildingUnlockCard.svelte @@ -8,6 +8,12 @@ bld: BuildingDefinition; enabled?: boolean; selectedUpgrade?: number; + /** + * The tier the upgrade plan takes this building to, or null when the plan + * leaves it alone. Marked on the tier row so the plan is visible where + * tiers are chosen; it is edited in the plan's own dialog. + */ + plannedUpgrade?: number | null; onToggle: () => void; onUpgradeChange: (level: number) => void; } @@ -16,6 +22,7 @@ bld, enabled = true, selectedUpgrade = 0, + plannedUpgrade = null, onToggle, onUpgradeChange, }: Props = $props(); @@ -114,6 +121,8 @@ type="button" class="tier-btn" class:active={selectedUpgrade === idx} + class:planned={plannedUpgrade === idx} + title={plannedUpgrade === idx ? "Planned upgrade" : undefined} disabled={!enabled} onclick={(e) => { e.stopPropagation(); @@ -356,6 +365,14 @@ opacity: 0.4; } + /* The upgrade plan's target: dashed, because it is a tier the player means + to have rather than one they hold. */ + .tier-btn.planned { + border-style: dashed; + border-color: var(--accent); + color: var(--accent); + } + @media (pointer: coarse) { /* * 18px tier chips are not hittable with a thumb. They wrap rather diff --git a/src/lib/components/sidebar/BuildingUnlockList.svelte b/src/lib/components/sidebar/BuildingUnlockList.svelte index c07021a..b580f0c 100755 --- a/src/lib/components/sidebar/BuildingUnlockList.svelte +++ b/src/lib/components/sidebar/BuildingUnlockList.svelte @@ -4,6 +4,7 @@ import { Cpu, Zap, Snowflake, Lock, Unlock } from "lucide-svelte"; import { onDestroy } from "svelte"; import BuildingUnlockCard from "./BuildingUnlockCard.svelte"; + import UpgradePlanSummary from "./UpgradePlanSummary.svelte"; import { createConfirmArm } from "../confirmArm.svelte"; /** @@ -85,6 +86,8 @@ in — worth having at the top of the list, not worth a permanent band across every screen of it. --> + +
+ + diff --git a/src/lib/simulation/upgradePlan.test.ts b/src/lib/simulation/upgradePlan.test.ts new file mode 100644 index 0000000..82553b9 --- /dev/null +++ b/src/lib/simulation/upgradePlan.test.ts @@ -0,0 +1,122 @@ +/** + * `evaluatePlan`: how a layout fares as the upgrade plan is bought. + * + * The board is three tiles — a reactor feeding a generator, one cooler beside + * it — sized so the cooler covers the generator's waste at the reactor's + * fourth tier and not at its sixth. More heat is more waste, and nothing else + * on the board changes, so the step that tips it over is known exactly. + */ +import { describe, expect, it } from "vitest"; +import { + BUILDINGS, + findBuilding, + getAnomaly, + levelValue, + makeGrid, + resolveUpgradePlan, + type UpgradeStep, +} from "@reactor2/solver"; +import { placementStatus, unscoredPlacement } from "../data/placements"; +import { simulatePlacedBuildings } from "./simulator"; +import { evaluatePlan } from "./upgradePlan"; + +const today = { heliothermal_plant: 0, generator2: 5, cooler2: 5 }; +const grid = makeGrid(["GGG"]); + +/** The three-tile layout, each building at the tier `unlocks` holds. */ +function layoutAt(unlocks: Record) { + return ["heliothermal_plant", "generator2", "cooler2"].map((id, x) => { + const def = findBuilding(id)!; + return unscoredPlacement(id, x, 0, levelValue(def.levels[unlocks[id]])); + }); +} + +function report(steps: UpgradeStep[], unlocks: Record = today) { + return evaluatePlan( + grid, + layoutAt(unlocks), + resolveUpgradePlan(BUILDINGS, unlocks, steps), + undefined, + getAnomaly(undefined), + ); +} + +describe("evaluatePlan", () => { + it("is null for a plan that upgrades nothing", () => { + expect(report([])).toBeNull(); + expect(report([{ buildingId: "heliothermal_plant", level: 0 }])).toBeNull(); + }); + + it("counts a building idle today and working after the plan as reserved", () => { + // A second cooler beside the generator. Cooling fills coolers in the + // game's processing order (columns left to right, rows bottom up), so the + // one below covers today's waste alone and the top-row one does nothing + // until the reactor's sixth tier sends more heat than one can take. + const unlocks = today; + const board = makeGrid(["GGG", "GGG"]); + const layout = [...layoutAt(unlocks), { ...layoutAt(unlocks)[2], y: 1 }]; + const scoredToday = simulatePlacedBuildings(board, BUILDINGS, layout); + expect(scoredToday.map(placementStatus)).toEqual([ + "active", + "active", + "idle", + "active", + ]); + + const r = evaluatePlan( + board, + scoredToday, + resolveUpgradePlan(BUILDINGS, unlocks, [ + { buildingId: "heliothermal_plant", level: 5 }, + ]), + undefined, + getAnomaly(undefined), + ); + expect(r?.failedStep).toBeNull(); + expect(r?.reserved).toBe(1); + }); + + it("reports the power at the end of a plan the layout survives", () => { + const r = report([{ buildingId: "heliothermal_plant", level: 3 }]); + expect(r?.failedStep).toBeNull(); + expect(r?.overheating).toBe(0); + // More heat into the same generator: more power than today's. + expect(r?.power).toBeGreaterThan(61_000); + }); + + it("names the first step that overheats, by its place in the list", () => { + const r = report([ + { buildingId: "heliothermal_plant", level: 3 }, + { buildingId: "heliothermal_plant", level: 5 }, + ]); + expect(r?.failedStep).toBe(1); + expect(r?.overheating).toBe(1); + // An overheated generator makes nothing. + expect(r?.power).toBe(0); + }); + + it("indexes the player's list, skipping steps that no longer count", () => { + // Step 0 is already bought, so the failure is the list's second entry. + const r = report([ + { buildingId: "heliothermal_plant", level: 0 }, + { buildingId: "heliothermal_plant", level: 5 }, + ]); + expect(r?.failedStep).toBe(1); + }); + + it("reports the power at the end even when a step on the way fails", () => { + // A smaller cooler today: the reactor's fourth tier outruns it, and the + // cooler upgrade after that puts it right. The player ends up with a + // running board and still needs telling about the step in between. + const r = report( + [ + { buildingId: "heliothermal_plant", level: 3 }, + { buildingId: "cooler2", level: 5 }, + ], + { ...today, cooler2: 4 }, + ); + expect(r?.failedStep).toBe(0); + expect(r?.overheating).toBe(1); + expect(r?.power).toBeGreaterThan(61_000); + }); +}); diff --git a/src/lib/simulation/upgradePlan.ts b/src/lib/simulation/upgradePlan.ts new file mode 100644 index 0000000..3bd5fba --- /dev/null +++ b/src/lib/simulation/upgradePlan.ts @@ -0,0 +1,104 @@ +import { + BUILDINGS, + type AnomalyDefinition, + type PrestigeScales, + type ResolvedUpgradePlan, +} from "@reactor2/solver"; +import { placementStatus, rebaseToUnlocks } from "../data/placements"; +import type { PlacedBuilding, Tile } from "../types"; +import { simulatePlacedBuildings } from "./simulator"; + +/** How a layout fares as the player works through their upgrade plan. */ +export interface PlanReport { + /** Power once every step is bought — what a plan solve is scored at. */ + power: number; + /** + * The first step after which a building on the layout overheats, as an + * index into the player's plan list, or null when it runs at every step. + */ + failedStep: number | null; + /** How many buildings overheat at `failedStep`; 0 when nothing fails. */ + overheating: number; + /** + * Buildings idle today that are working once the plan is bought — mostly + * coolers a plan holds back for heat that has not arrived yet. + * + * Counted so the readout can tell them apart from idle buildings that are + * simply wasted: both read as idle today, and only one of them should be + * torn down. Printed as idle, the reserve the plan paid for is exactly what + * a tidy player would remove. + */ + reserved: number; +} + +/** + * Scores a layout at each step of an upgrade plan, re-reading every + * placement's tier from that step's roster the way buying the upgrade would. + * + * Today's roster is not re-checked: the placements already carry today's + * tiers, and their own figures are the board's — which is also what + * `reserved` reads, so they must arrive scored. What this adds is the future, + * so it answers for **any** layout, not only one searched under the plan: a + * solve found without it is told plainly that the upgrade will shut part of + * it down, rather than finding out in the game. + * + * Every step goes through `simulatePlacedBuildings`, the scorer that rates the + * board on screen, so the report and the readout above it cannot disagree about + * what a building does. + * + * Null when the plan upgrades nothing, which is the same as having no plan. + */ +export function evaluatePlan( + grid: Tile[][], + placements: readonly PlacedBuilding[], + plan: ResolvedUpgradePlan, + prestige: PrestigeScales | undefined, + anomaly: AnomalyDefinition, +): PlanReport | null { + if (plan.along.length === 0) return null; + + // The rosters after each effective step: the rest of `along`, then the + // target. `along[0]` is today. + const rosters = [...plan.along.slice(1), plan.target]; + const stepIndices = plan.effective.flatMap((on, i) => (on ? [i] : [])); + + const report: PlanReport = { + power: 0, + failedStep: null, + overheating: 0, + reserved: 0, + }; + for (let k = 0; k < rosters.length; k++) { + const rebased = rebaseToUnlocks(placements, rosters[k]) ?? placements; + const scored = simulatePlacedBuildings( + grid, + BUILDINGS, + rebased as PlacedBuilding[], + prestige, + anomaly, + ); + // Every step is scored even after one fails: the power at the end is what + // the player ends up with either way, and printing it beside the failure + // is what says how much the failure costs. + if (report.failedStep === null) { + const overheating = scored.filter( + (p) => placementStatus(p) === "starved", + ).length; + if (overheating > 0) { + report.failedStep = stepIndices[k]; + report.overheating = overheating; + } + } + if (k === rosters.length - 1) { + report.power = scored.reduce((sum, p) => sum + p.powerGenerated, 0); + // `placements` carry today's figures and `scored` is in the same order. + report.reserved = placements.filter( + (p, i) => + placementStatus(p) === "idle" && + placementStatus(scored[i]) === "active", + ).length; + } + } + + return report; +} diff --git a/src/lib/state/CLAUDE.md b/src/lib/state/CLAUDE.md index 3380598..1c9bca1 100644 --- a/src/lib/state/CLAUDE.md +++ b/src/lib/state/CLAUDE.md @@ -30,6 +30,40 @@ author's research, and a blueprint does not record what that was. 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 +`components/inspector/CLAUDE.md`. + +- **`configState.upgradePlan` stores what the player wrote; `resolvedPlan` is + what counts.** A step the roster has caught up with (the upgrade was bought), + or whose building is locked, stays in the list, dims, and stops counting, so + a plan is carried out rather than edited away. `planKey` spells only the + effective steps. +- **A run searches at the plan's target and comes back at today's tiers.** + `runOptimizer` hands the coordinator `resolvedPlan.target` as the roster and + `along` as `upgradePlan`, then re-bases every streamed and finished layout to + today's unlocks and re-scores it (`toToday`). The board, the readout, a share + code and a copy onto the user's board all describe what can be built now. +- **"Better" is the power after the plan.** That is what the search maximised, + so `chooseSolveVariants` takes a `rank`, and under a plan it is + `evaluatePlan(...).power` — for the defended bar, the streamed-snapshot gate + and the final comparison alike. Today's power is a consequence of the plan, + not its aim. +- **The plan is not in `solveSignature()`.** The signature says whether a + layout is still an answer for this board; a plan does not change that, it + changes what the layout was optimised for. In the signature, editing the plan + would have discarded every other island's stored solve on its next restore. + `resultPlanKey` (stored as `SavedSolveData.planKey`) records what the shape + was searched under instead, and `requestSolve` offers "keep the better one" + only when it matches, the same rule as `resultAnomalyId`. +- **`uiState.planReport` is `$derived` over `visiblePlacements`**, so it judges + whatever board is on screen — a plan solve, a solve found without the plan, + or the player's own — through `simulation/upgradePlan.ts`'s `evaluatePlan`, + which scores each step with the same `simulatePlacedBuildings` the board + uses. Null during a run and on a preview, which is someone else's board at + their tiers. + ## The stores **`layoutState` imports nothing from the rest of the state layer.** It is the diff --git a/src/lib/state/config.svelte.ts b/src/lib/state/config.svelte.ts index fd85609..2e3170c 100755 --- a/src/lib/state/config.svelte.ts +++ b/src/lib/state/config.svelte.ts @@ -3,15 +3,20 @@ import { DEFAULT_ANOMALY_ID, getAnomaly, getEffectiveBuildings, + MAX_UPGRADE_STEPS, prestigeScales, + resolveUpgradePlan, rosterCanProducePower, type AnomalyDefinition, type PrestigeScales, + type ResolvedUpgradePlan, + type UpgradeStep, } from "@reactor2/solver"; import { anomalyStorage, buildingStorage, prestigeStorage, + upgradePlanStorage, } from "../storage/storage"; import { trackEvent } from "../utils/analytics"; @@ -71,10 +76,120 @@ class ConfigState { */ prestige: PrestigeScales = $derived(prestigeScales(this.prestigeLevels)); + /** + * The upgrade plan: up to `MAX_UPGRADE_STEPS` tiers the player means to buy, + * **in buying order**. Persisted. + * + * The order is the whole point rather than a detail. A layout that survives + * "cooler, then reactor" can overheat if the reactor comes first, and a plan + * that had to survive every order was measured at 31-59% of the power a + * fixed order keeps — so the player states the order they will buy in, and + * the solve holds for that one. + * + * Stored as the player wrote it, not as it resolves: a step the roster has + * since caught up with (the upgrade was bought) stays in the list and simply + * stops counting, so the plan is carried out rather than edited away. + */ + upgradePlan = $state([]); + + /** + * The plan against the current roster: the rosters a run is held to, and + * which steps still upgrade anything. `$derived` so the Setup list, the run + * and the readout all read one resolution. + */ + resolvedPlan: ResolvedUpgradePlan = $derived( + resolveUpgradePlan(BUILDINGS, this.buildingUpgrades, this.upgradePlan), + ); + constructor() { this.loadCatalog(); this.anomalyId = anomalyStorage.loadAnomaly(); this.prestigeLevels = prestigeStorage.loadPrestige(); + this.upgradePlan = upgradePlanStorage.loadPlan(); + } + + /** Whether any step of the plan still upgrades something. */ + get hasPlan(): boolean { + return this.resolvedPlan.along.length > 0; + } + + /** + * The plan's effective steps as one string — empty with no plan — so a + * result can record which plan it was searched under and be compared with + * the current one. Only steps that still count go in, so buying a planned + * upgrade changes the key exactly as it changes what a run would do. + */ + get planKey(): string { + return this.upgradePlan + .filter((_, i) => this.resolvedPlan.effective[i]) + .map((s) => `${s.buildingId}:${s.level}`) + .join(">"); + } + + /** + * Where `id` stands once the whole plan is bought, if the plan takes it + * higher than the roster does — what a building's card marks as planned. + * Null when the plan leaves it alone. + */ + plannedLevel(id: string): number | null { + const planned = this.resolvedPlan.target[id]; + const held = this.buildingUpgrades[id]; + return planned !== undefined && planned !== held ? planned : null; + } + + /** + * Whether `id` can take another step: unlocked, and below its top tier even + * after what the plan already buys. Maxed buildings are not offered, since a + * step for one would upgrade nothing. + */ + canPlan(id: string): boolean { + const def = BUILDINGS.find((b) => b.id === id); + const at = this.resolvedPlan.target[id]; + return def !== undefined && at !== undefined && at < def.levels.length - 1; + } + + /** + * Appends a step for `id`, one tier above where the plan leaves it — the + * smallest purchase, which the step's tier row can raise. Does nothing at + * the limit or for a building with no tier left to buy. + */ + addPlanStep(id: string) { + if (this.upgradePlan.length >= MAX_UPGRADE_STEPS) return; + if (!this.canPlan(id)) return; + this.upgradePlan.push({ + buildingId: id, + level: this.resolvedPlan.target[id] + 1, + }); + this.#savePlan(); + } + + /** + * Moves a step one place earlier (`-1`) or later (`+1`). The order is the + * buying order, and it decides what the layout has to survive, so it is + * editable rather than fixed by the order steps happened to be added in. + */ + movePlanStep(index: number, delta: -1 | 1) { + const to = index + delta; + if (index < 0 || to < 0 || to >= this.upgradePlan.length) return; + const [step] = this.upgradePlan.splice(index, 1); + this.upgradePlan.splice(to, 0, step); + this.#savePlan(); + } + + setPlanStep(index: number, step: UpgradeStep) { + if (index < 0 || index >= this.upgradePlan.length) return; + this.upgradePlan[index] = { ...step }; + this.#savePlan(); + } + + removePlanStep(index: number) { + if (index < 0 || index >= this.upgradePlan.length) return; + this.upgradePlan.splice(index, 1); + this.#savePlan(); + } + + #savePlan() { + upgradePlanStorage.savePlan($state.snapshot(this.upgradePlan)); } loadCatalog() { diff --git a/src/lib/state/solveVariants.test.ts b/src/lib/state/solveVariants.test.ts index 6445d5d..e01634a 100755 --- a/src/lib/state/solveVariants.test.ts +++ b/src/lib/state/solveVariants.test.ts @@ -161,6 +161,28 @@ describe("what a finished run does to the shortlist", () => { expect(chosen.defended).toBe(true); }); + it("ranks by the figure it is handed, so a plan is judged after the plan", () => { + // Under an upgrade plan the search maximises the power once the plan is + // bought; today's figure is a side effect. A run lower today but higher + // after the plan has to win, and one higher today but lower after has to + // lose, or "keep the better one" defends against the wrong number. + const after = new Map(); + const held = result(100, 0); + const fresh = result(80, 9); + after.set(held, 150); + after.set(fresh, 200); + const rank = (r: OptimizationResult) => after.get(r) ?? 0; + + const won = chooseSolveVariants([held], 0, [fresh], 10, rank); + expect(won.defended).toBe(false); + expect(won.variants).toEqual([fresh]); + + after.set(fresh, 120); + const lost = chooseSolveVariants([held], 0, [fresh], 10, rank); + expect(lost.defended).toBe(true); + expect(lost.variants).toEqual([held]); + }); + it("never grows past the limit", () => { const held = Array.from({ length: 8 }, (_, i) => spread(500, i)); const fresh = Array.from({ length: 8 }, (_, i) => spread(500, i + 8)); diff --git a/src/lib/state/solver.svelte.ts b/src/lib/state/solver.svelte.ts index 4c78086..ad0c5f0 100755 --- a/src/lib/state/solver.svelte.ts +++ b/src/lib/state/solver.svelte.ts @@ -25,6 +25,7 @@ import { } from "@reactor2/solver"; import { rebaseToUnlocks, unscoredPlacement } from "../data/placements"; import { simulatePlacedBuildings } from "../simulation/simulator"; +import { evaluatePlan } from "../simulation/upgradePlan"; import { blueprintKey } from "@reactor2/solver"; import { trackEvent } from "../utils/analytics"; import { @@ -86,6 +87,7 @@ export function chooseSolveVariants( heldIndex: number, fresh: OptimizationResult[], limit: number = MAX_SOLVE_VARIANTS, + rank: (result: OptimizationResult) => number = (r) => r.totalPower, ): { variants: OptimizationResult[]; selected: number; defended: boolean } { if (fresh.length === 0) return { variants: held, selected: heldIndex, defended: true }; @@ -95,11 +97,12 @@ export function chooseSolveVariants( // A stored index can outrun a shortlist that has since been rebuilt; the // first entry is the layout the search settled on, so it is the safe home. const selected = Math.min(Math.max(heldIndex, 0), held.length - 1); - const bestHeld = held.reduce((best, v) => - v.totalPower > best.totalPower ? v : best, - ); + // Under an upgrade plan `rank` is the power *after* the plan, which is what + // the search maximised; `totalPower` is today's, which it did not. + const bestHeldPower = Math.max(...held.map(rank)); + const freshPower = rank(fresh[0]); - if (powerTies(fresh[0].totalPower, bestHeld.totalPower)) { + if (powerTies(freshPower, bestHeldPower)) { const variants = held.slice(); for (const variant of fresh) { if (variants.length >= limit) break; @@ -115,7 +118,7 @@ export function chooseSolveVariants( return { variants, selected, defended: false }; } - if (bestHeld.totalPower > fresh[0].totalPower) { + if (bestHeldPower > freshPower) { return { variants: held, selected, defended: true }; } return { variants: fresh.slice(0, limit), selected: 0, defended: false }; @@ -227,6 +230,22 @@ class SolverState { */ resultAnomalyId = $state(null); + /** + * The upgrade plan the on-screen shortlist was searched under, as + * `configState.planKey` spells it — empty for no plan, null with no result. + * + * The plan's counterpart to `resultAnomalyId`, and deliberately **not** part + * of `solveSignature()`. The signature says whether a layout is still an + * answer for this board at all; a plan does not change that — a layout + * searched without one runs today exactly as it did — it changes what the + * layout was optimised *for*. Folded into the signature, editing the plan + * would have discarded every other island's stored solve on its next + * restore, for layouts that are still perfectly good. What the plan does + * decide is whether a held layout is a fair bar for the next run + * (`uiState.requestSolve`), and that is what this is for. + */ + resultPlanKey = $state(null); + /** * How the next run spends its time: one long search, or several short ones. * @@ -494,6 +513,7 @@ class SolverState { // was already folded into `signature` by then, and the signature matched // to get here, so the current selection is what it was found under. this.resultAnomalyId = saved.anomalyId ?? configState.activeAnomaly.id; + this.resultPlanKey = saved.planKey ?? ""; } /** @@ -554,6 +574,7 @@ class SolverState { this.elapsedMs = 0; this.isRestored = false; this.resultAnomalyId = null; + this.resultPlanKey = null; } /** @@ -657,6 +678,7 @@ class SolverState { templateId: string, signature: string, anomalyId: AnomalyId, + planKey: string, ) { const applied = variants[selectedVariant]; if (!applied) return; @@ -672,6 +694,7 @@ class SolverState { ), selectedVariant, anomalyId, + planKey, durationMs, finishedAt, }); @@ -701,6 +724,7 @@ class SolverState { layoutState.activeTemplateId, this.solveSignature(), this.resultAnomalyId ?? configState.activeAnomaly.id, + this.resultPlanKey ?? "", ); } @@ -731,6 +755,38 @@ class SolverState { const previousVariants = options.keepBest && this.variants.length > 0 ? this.variants : []; const previousIndex = this.variantIndex; + + /* + * The upgrade plan, resolved once at launch like the rules beside it. + * + * Under a plan the search is handed the plan's *target* tiers and scores + * there, while every layout it keeps also has to run at today's tiers and + * at each step between. What comes back is therefore rated for tiers the + * player does not hold yet, and `toToday` puts it back on the tiers they + * do: the board, the readout and a share code all describe what can be + * built now, and the readout's plan line says what it becomes. + * + * "Better" changes with it. The search maximised the power at the end of + * the plan, so that is the figure a held layout is defended on and a fresh + * one judged by — today's power is a consequence of the plan, not its aim. + */ + const runPlan = configState.hasPlan + ? $state.snapshot(configState.resolvedPlan) + : null; + const runPlanKey = configState.planKey; + const runGrid = $state.snapshot(layoutState.grid); + const runPrestige = configState.prestige; + const runAnomaly = configState.activeAnomaly; + const rank = (result: OptimizationResult): number => + runPlan + ? (evaluatePlan( + runGrid, + result.placements, + runPlan, + runPrestige, + runAnomaly, + )?.power ?? 0) + : result.totalPower; /* * The bar a streaming snapshot has to clear to be worth putting on screen, * and it is the same bar `chooseSolveVariants` will judge the finished run @@ -741,11 +797,12 @@ class SolverState { */ const defendedPower = previousVariants.length > 0 - ? previousVariants.reduce((best, v) => Math.max(best, v.totalPower), 0) + ? previousVariants.reduce((best, v) => Math.max(best, rank(v)), 0) : null; const previousDurationMs = this.lastRunDurationMs; const previousFinishedAt = this.finishedAt; const previousAnomalyId = this.resultAnomalyId; + const previousPlanKey = this.resultPlanKey; // Captured before anything can await: this is the island being solved, the // board it is being solved against, and the rules it is solved under. @@ -759,6 +816,7 @@ class SolverState { // anomaly launched with, which the player may move off before either // lands — see `resultAnomalyId`. this.resultAnomalyId = this.#runAnomalyId; + this.resultPlanKey = runPlanKey; this.isOptimizing = true; this.isStopping = false; @@ -776,6 +834,7 @@ class SolverState { island: this.#runTemplateId, tiles: countGrassTiles(layoutState.grid), roster: Object.keys(configState.buildingUpgrades).length, + plan_steps: runPlan?.along.length ?? 0, ...runRules, cores: typeof navigator !== "undefined" @@ -795,20 +854,31 @@ class SolverState { try { // $state.snapshot is required before anything crosses postMessage: // reactive proxies are not structured-cloneable. - const grid = $state.snapshot(layoutState.grid); + const grid = runGrid; // BUILDINGS is static, so it needs no snapshot — only a mutable // copy, because solveProgressive hands it to postMessage. const buildings = [...BUILDINGS]; const upgrades = $state.snapshot(configState.buildingUpgrades); + // Only needed under a plan, where every result is re-scored on the way + // in; computing it re-splits the grid. + const todayBound = runPlan ? this.estimatedMaxPower : 0; + const toToday = (result: OptimizationResult): OptimizationResult => + runPlan + ? this.#scoreLayout( + rebaseToUnlocks(result.placements, upgrades) ?? result.placements, + todayBound, + ) + : result; const mode = this.solveMode; const handle = this.#client.solveProgressive( grid, buildings, - upgrades, - (progressResult) => { + runPlan ? runPlan.target : upgrades, + (streamed) => { // A run outlives a template switch; its progress must not. if (!onScreen()) return; + const progressResult = toToday(streamed); /* * A progress report is a search still moving, and for most of a run * it is well below what that search will finish at. Streamed over a @@ -825,8 +895,8 @@ class SolverState { */ if ( defendedPower !== null && - (progressResult.totalPower <= defendedPower || - powerTies(progressResult.totalPower, defendedPower)) + (rank(progressResult) <= defendedPower || + powerTies(rank(progressResult), defendedPower)) ) return; this.optimizationResult = progressResult; @@ -842,12 +912,13 @@ class SolverState { // layout, or in how the board is split. anomalyId: this.#runAnomalyId, prestige: configState.prestige, + upgradePlan: runPlan?.along, }, ); this.activeTask = handle; this.#startWatchdog(mode); - finalVariants = await handle.promise; + finalVariants = (await handle.promise).map(toToday); if (onScreen()) this.optimizationResult = finalVariants[0] ?? null; } catch (err: any) { runError = err?.message || "Optimization failed."; @@ -877,6 +948,8 @@ class SolverState { previousVariants, previousIndex, finalVariants, + MAX_SOLVE_VARIANTS, + rank, ); // A defended layout keeps its own run's clock: it is still that solve, @@ -894,6 +967,9 @@ class SolverState { const winningAnomalyId = defended ? (previousAnomalyId ?? this.#runAnomalyId) : this.#runAnomalyId; + const winningPlanKey = defended + ? (previousPlanKey ?? runPlanKey) + : runPlanKey; // Filed under the island it was started on, whether or not that is // still the one being looked at. @@ -905,6 +981,7 @@ class SolverState { this.#runTemplateId, this.#runSignature, winningAnomalyId, + winningPlanKey, ); if (onScreen()) { this.variants = variants; @@ -915,6 +992,7 @@ class SolverState { this.lastRunDurationMs = winningDurationMs; this.finishedAt = finishedAt; this.resultAnomalyId = winningAnomalyId; + this.resultPlanKey = winningPlanKey; } donePower = variants[selected]?.totalPower ?? 0; } else if (onScreen()) { @@ -935,6 +1013,7 @@ class SolverState { this.finishedAt = previousFinishedAt; this.elapsedMs = previousDurationMs ?? 0; this.resultAnomalyId = previousAnomalyId; + this.resultPlanKey = previousPlanKey; } // Every `solve_run` gets one of these, so a status other than `ok` is @@ -950,6 +1029,7 @@ class SolverState { bound: Math.round(bound), bound_pct: bound > 0 ? Math.round((donePower / bound) * 100) : 0, variants: this.variants.length, + plan_steps: runPlan?.along.length ?? 0, ...runRules, }); } diff --git a/src/lib/state/ui.svelte.ts b/src/lib/state/ui.svelte.ts index 348a921..9510f53 100755 --- a/src/lib/state/ui.svelte.ts +++ b/src/lib/state/ui.svelte.ts @@ -11,6 +11,7 @@ import { trackEvent, } from "../utils/analytics"; import { uiStorage } from "../storage/storage"; +import { evaluatePlan, type PlanReport } from "../simulation/upgradePlan"; import { configState } from "./config.svelte"; import { layoutState } from "./layout.svelte"; import { solverState } from "./solver.svelte"; @@ -25,7 +26,13 @@ interface TileRef { /** The dialogs the app can have open. Only ever one at a time. */ type ActiveModal = - "share" | "import" | "solve" | "solveModes" | "settings" | null; + | "share" + | "import" + | "solve" + | "solveModes" + | "settings" + | "upgradePlan" + | null; /** * The two forms a layout can be handed to someone else in. @@ -552,6 +559,37 @@ class UIState { return layoutState.placements.reduce((sum, p) => sum + p.powerGenerated, 0); } + /** + * How the board on screen fares through the upgrade plan — power once every + * step is bought, and the first step that shuts a building down — or null + * when there is no plan or nothing to judge. + * + * It follows `visiblePlacements` rather than the solve, so it says the same + * thing about any layout: a solve searched under the plan (which never + * fails a step), one searched without it, and the player's own board. That + * is also the warning the plan buys for free — a layout found before the + * plan existed is told the upgrade will overheat it, before the purchase + * rather than after. + * + * `$derived` because it is a simulation per step and the readout reads it on + * every render. Absent while a run is in flight, whose streamed layouts are + * not answers yet, and on a preview, which is someone else's board at their + * own tiers — the reader's plan does not speak for it. + */ + planReport: PlanReport | null = $derived.by(() => { + if (!configState.hasPlan || this.isPreview) return null; + if (solverState.isOptimizing) return null; + const placements = this.visiblePlacements; + if (placements.length === 0) return null; + return evaluatePlan( + layoutState.grid, + placements, + configState.resolvedPlan, + configState.prestige, + configState.activeAnomaly, + ); + }); + /** * Which stats panel `BoardStatsCard` shows, if any. * @@ -638,10 +676,16 @@ class UIState { * which is whatever `localStorage` held: `resultAnomalyId` is always a * resolved id, so an unknown string would otherwise never match one and * the dialog would be skipped for good. + * + * The upgrade plan is the same question asked of a different input. A + * layout searched for another plan was maximising another figure, and + * under a plan the comparison is made on the power *after* it — so the + * held layout is only a fair bar when both runs aim at the same plan. */ if ( solverState.optimizationResult && - solverState.resultAnomalyId === configState.activeAnomaly.id + solverState.resultAnomalyId === configState.activeAnomaly.id && + solverState.resultPlanKey === configState.planKey ) { this.activeModal = "solve"; return; @@ -991,6 +1035,15 @@ class UIState { this.activeModal = "solveModes"; } + /** + * Opens the upgrade plan editor. A dialog rather than a section of Setup: + * each step wants an icon, a tier row and reordering, which would push the + * roster down the Buildings tab on every visit for a thing set once. + */ + openUpgradePlan() { + this.activeModal = "upgradePlan"; + } + closeModal() { this.activeModal = null; } diff --git a/src/lib/state/upgradePlan.test.ts b/src/lib/state/upgradePlan.test.ts new file mode 100644 index 0000000..746e2ac --- /dev/null +++ b/src/lib/state/upgradePlan.test.ts @@ -0,0 +1,96 @@ +/** + * The upgrade plan's editing verbs on `configState`. + * + * The resolver underneath is pinned in the solver package; these pin what the + * dialog relies on: a maxed building is never offered, a new step is one tier + * up from wherever the plan already leaves its building, the order can be + * changed, and a building's card can ask what tier the plan takes it to. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { MAX_UPGRADE_STEPS } from "@reactor2/solver"; +import { configState } from "./config.svelte"; + +beforeEach(() => { + configState.lockAll(); + while (configState.upgradePlan.length > 0) configState.removePlanStep(0); + // generator4 has six tiers (0-5), cooler4 eight (0-7). + configState.setUpgradeLevel("generator4", 3); + configState.setUpgradeLevel("cooler4", 7); + configState.setUpgradeLevel("quantum_reactor", 1); +}); + +describe("which buildings a step may name", () => { + it("offers an unlocked building below its top tier", () => { + expect(configState.canPlan("generator4")).toBe(true); + }); + + it("does not offer a maxed building", () => { + expect(configState.canPlan("cooler4")).toBe(false); + configState.addPlanStep("cooler4"); + expect(configState.upgradePlan).toEqual([]); + }); + + it("does not offer a locked one", () => { + expect(configState.canPlan("generator7")).toBe(false); + }); + + it("stops offering a building once the plan takes it to the top", () => { + configState.addPlanStep("generator4"); + configState.setPlanStep(0, { buildingId: "generator4", level: 5 }); + expect(configState.canPlan("generator4")).toBe(false); + }); +}); + +describe("adding and ordering steps", () => { + it("starts a step one tier above where the plan leaves the building", () => { + configState.addPlanStep("generator4"); + configState.addPlanStep("generator4"); + expect(configState.upgradePlan).toEqual([ + { buildingId: "generator4", level: 4 }, + { buildingId: "generator4", level: 5 }, + ]); + }); + + it(`holds no more than ${MAX_UPGRADE_STEPS} steps`, () => { + configState.addPlanStep("quantum_reactor"); + configState.addPlanStep("quantum_reactor"); + configState.addPlanStep("quantum_reactor"); + configState.addPlanStep("generator4"); + expect(configState.upgradePlan).toHaveLength(MAX_UPGRADE_STEPS); + }); + + it("moves a step earlier or later, and not past either end", () => { + configState.addPlanStep("generator4"); + configState.addPlanStep("quantum_reactor"); + configState.movePlanStep(1, -1); + expect(configState.upgradePlan.map((s) => s.buildingId)).toEqual([ + "quantum_reactor", + "generator4", + ]); + configState.movePlanStep(0, -1); + configState.movePlanStep(1, 1); + expect(configState.upgradePlan.map((s) => s.buildingId)).toEqual([ + "quantum_reactor", + "generator4", + ]); + }); +}); + +describe("the planned tier a card marks", () => { + it("is where the whole plan leaves the building", () => { + configState.addPlanStep("generator4"); + configState.addPlanStep("generator4"); + expect(configState.plannedLevel("generator4")).toBe(5); + }); + + it("is null for a building the plan leaves alone", () => { + configState.addPlanStep("generator4"); + expect(configState.plannedLevel("quantum_reactor")).toBeNull(); + }); + + it("is null once the roster has caught up — the tier was bought", () => { + configState.addPlanStep("generator4"); + configState.setUpgradeLevel("generator4", 4); + expect(configState.plannedLevel("generator4")).toBeNull(); + }); +}); diff --git a/src/lib/storage/storage.ts b/src/lib/storage/storage.ts index a93c6d7..7b7ec9f 100755 --- a/src/lib/storage/storage.ts +++ b/src/lib/storage/storage.ts @@ -3,8 +3,10 @@ import type { ImageScale, PlacementView } from "../types/ui"; import { DEFAULT_SOLVE_MODE, type SolveModeId } from "../worker/solveModes"; import { DEFAULT_ANOMALY_ID, + MAX_UPGRADE_STEPS, type AnomalyId, type OptimizationResult, + type UpgradeStep, } from "@reactor2/solver"; export interface SavedTemplateData { @@ -24,6 +26,7 @@ const KEYS = { BUILDINGS: "buildings", ANOMALY: "anomaly", PRESTIGE: "prestige", + UPGRADE_PLAN: "upgrade_plan", UI: "ui_prefs", SOLVE: "solver_result", } as const; @@ -140,6 +143,13 @@ export interface SavedSolveData { * signature already settles it — see `solverState.restore`. */ anomalyId?: AnomalyId; + /** + * The upgrade plan the shapes were searched under, as `configState.planKey` + * spells it — empty for a solve found with no plan. Kept beside the record + * rather than inside `signature` for the reason `anomalyId` is: see + * `solverState.resultPlanKey`. Absent on older records, which had no plan. + */ + planKey?: string; /** Wall-clock length of the run, in ms. */ durationMs: number; /** When it finished, epoch ms. Shown as "solved N ago". */ @@ -237,6 +247,34 @@ export const prestigeStorage = { setItem(KEYS.PRESTIGE, data), }; +/** + * The upgrade plan: the next few tiers the player means to buy, in buying + * order. + * + * Its own key, like the research beside it, because it is a solve input set + * from its own control. Read defensively — it comes off `localStorage`, so + * anything that is not a list of `{ buildingId, level }` pairs is dropped + * rather than handed to the resolver, and a list longer than the plan allows + * is cut to length. + */ +export const upgradePlanStorage = { + loadPlan: (): UpgradeStep[] => { + const raw = getItem(KEYS.UPGRADE_PLAN, []); + if (!Array.isArray(raw)) return []; + return raw + .filter( + (s): s is UpgradeStep => + typeof s === "object" && + s !== null && + typeof s.buildingId === "string" && + Number.isInteger(s.level), + ) + .slice(0, MAX_UPGRADE_STEPS) + .map(({ buildingId, level }) => ({ buildingId, level })); + }, + savePlan: (steps: UpgradeStep[]): void => setItem(KEYS.UPGRADE_PLAN, steps), +}; + export const uiStorage = { loadPrefs: (): UiPrefs => ({ ...DEFAULT_UI_PREFS, diff --git a/src/lib/worker/islandWorker.ts b/src/lib/worker/islandWorker.ts index 48de8f6..9e8956f 100755 --- a/src/lib/worker/islandWorker.ts +++ b/src/lib/worker/islandWorker.ts @@ -44,6 +44,7 @@ self.addEventListener( }, req.rngSeed, getAnomaly(req.anomalyId), + req.upgradeSteps, ); post({ id: req.id, diff --git a/src/lib/worker/solverCoordinator.ts b/src/lib/worker/solverCoordinator.ts index 86fbb43..af68df7 100755 --- a/src/lib/worker/solverCoordinator.ts +++ b/src/lib/worker/solverCoordinator.ts @@ -103,6 +103,7 @@ export class SolverCoordinator { rngSeed, options?.anomalyId, options?.prestige, + options?.upgradePlan, ); if (!plan) return [createEmptyResult(grid?.[0]?.length ? countGrassTiles(grid) : 0)]; @@ -285,6 +286,10 @@ export class SolverCoordinator { // it again on the far side, which keeps the message a plain string // rather than a table entry that has to survive structured cloning. anomalyId: plan.anomaly.id, + // Resolved once, like the roster beside it; absent without a plan. + upgradeSteps: plan.upgradeSteps?.length + ? plan.upgradeSteps + : undefined, // Every attempt needs its own random stream — running the same walk // ten times over would only cost ten times as much. An unseeded run // gets a fresh stream per task already; a seeded one is offset so it diff --git a/src/lib/worker/workerClient.ts b/src/lib/worker/workerClient.ts index afd605c..6ae51f2 100755 --- a/src/lib/worker/workerClient.ts +++ b/src/lib/worker/workerClient.ts @@ -75,6 +75,13 @@ export interface SolveRunOptions { * already carries it. */ prestige?: PrestigeScales; + /** + * The upgrade plan's rosters before its target, today's first — see + * `SolveOptions.upgradePlan`. When set, `unlockedUpgrades` is the plan's + * target rather than the player's roster: the search scores there and keeps + * only layouts that also run at every one of these. + */ + upgradePlan?: Record[]; } export interface SolveTaskHandle { @@ -140,6 +147,7 @@ export class SolverWorkerClient { attempts: run?.attempts, anomalyId: run?.anomalyId, prestige: run?.prestige, + upgradePlan: run?.upgradePlan, reportIntervalMs: this.options.reportIntervalMs, onProgress: (result) => { if (!entry.stopped) entry.onProgress?.(result);