Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions docs/SOLVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/solver/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
83 changes: 83 additions & 0 deletions packages/solver/src/data/upgradePlan.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
/**
* 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<string, number>[];
/**
* 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<string, number>,
steps: readonly UpgradeStep[],
): ResolvedUpgradePlan {
const along: Record<string, number>[] = [];
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 };
}
1 change: 1 addition & 0 deletions packages/solver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading